mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-27 22:49:56 -05:00
Changed code based on feedback
ref https://github.com/TryGhost/Ghost/pull/21861#discussion_r1881701985 - simplified disabled logic to be within the component instead of globally. - added state change in a single dispatch query that can handle like / unlike as well as fallback handling. - updated test
This commit is contained in:
parent
c0071db5b2
commit
76d08f1696
3 changed files with 66 additions and 128 deletions
|
@ -189,39 +189,17 @@ async function showComment({state, api, data: comment}: {state: EditableAppConte
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendLikeToApi({api, data: comment}: {api: GhostApi, data: {id: string, commentsState: Comment[]}}) {
|
async function updateCommentLikeState({state, data: comment}: {state: EditableAppContext, data: {id: string, liked: boolean, commentsState: Comment[]}}) {
|
||||||
const commentsState = comment.commentsState;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.comments.like({comment: {id: comment.id}});
|
|
||||||
return {
|
|
||||||
commentLikeLoadingId: null
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
// if error we revert the state
|
|
||||||
return {
|
|
||||||
comments: commentsState,
|
|
||||||
commentLikeLoadingId: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function likeComment({state, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
|
|
||||||
const exisitngState = state.comments;
|
|
||||||
|
|
||||||
dispatchAction('sendLikeToApi', {id: comment.id, commentsState: exisitngState});
|
|
||||||
|
|
||||||
// optimistic update
|
|
||||||
return {
|
return {
|
||||||
comments: state.comments.map((c) => {
|
comments: state.comments.map((c) => {
|
||||||
const replies = c.replies.map((r) => {
|
const replies = c.replies.map((r) => {
|
||||||
if (r.id === comment.id) {
|
if (r.id === comment.id) {
|
||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
liked: true,
|
liked: comment.liked,
|
||||||
count: {
|
count: {
|
||||||
...r.count,
|
...r.count,
|
||||||
likes: r.count.likes + 1
|
likes: comment.liked ? r.count.likes + 1 : r.count.likes - 1
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -232,11 +210,11 @@ async function likeComment({state, data: comment, dispatchAction}: {state: Edita
|
||||||
if (c.id === comment.id) {
|
if (c.id === comment.id) {
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
liked: true,
|
liked: comment.liked,
|
||||||
replies,
|
replies,
|
||||||
count: {
|
count: {
|
||||||
...c.count,
|
...c.count,
|
||||||
likes: c.count.likes + 1
|
likes: comment.liked ? c.count.likes + 1 : c.count.likes - 1
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -245,69 +223,29 @@ async function likeComment({state, data: comment, dispatchAction}: {state: Edita
|
||||||
...c,
|
...c,
|
||||||
replies
|
replies
|
||||||
};
|
};
|
||||||
}),
|
})
|
||||||
commentLikeLoadingId: comment.id
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendUnlikeToApi({api, data: comment}: {api: GhostApi, data: {id: string, commentsState: Comment[]}}) {
|
async function likeComment({state, api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
|
||||||
const commentsState = comment.commentsState;
|
dispatchAction('updateCommentLikeState', {id: comment.id, liked: true, commentsState: state.comments});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.comments.unlike({comment: {id: comment.id}});
|
await api.comments.like({comment});
|
||||||
return {
|
return {};
|
||||||
commentUnlikeLoading: null
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If error, revert the state
|
dispatchAction('updateCommentLikeState', {id: comment.id, liked: false, commentsState: state.comments});
|
||||||
return {
|
|
||||||
comments: commentsState,
|
|
||||||
commentUnlikeLoading: null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unlikeComment({state, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
|
async function unlikeComment({state, api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
|
||||||
const existingState = state.comments;
|
dispatchAction('updateCommentLikeState', {id: comment.id, liked: false, commentsState: state.comments});
|
||||||
|
|
||||||
dispatchAction('sendUnlikeToApi', {id: comment.id, commentsState: existingState});
|
try {
|
||||||
// optimistic update
|
await api.comments.unlike({comment});
|
||||||
return {
|
return {};
|
||||||
comments: state.comments.map((c) => {
|
} catch (err) {
|
||||||
const replies = c.replies.map((r) => {
|
dispatchAction('updateCommentLikeState', {id: comment.id, liked: true, commentsState: state.comments});
|
||||||
if (r.id === comment.id) {
|
}
|
||||||
return {
|
|
||||||
...r,
|
|
||||||
liked: false,
|
|
||||||
count: {
|
|
||||||
...r.count,
|
|
||||||
likes: r.count.likes - 1
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (c.id === comment.id) {
|
|
||||||
return {
|
|
||||||
...c,
|
|
||||||
liked: false,
|
|
||||||
replies,
|
|
||||||
count: {
|
|
||||||
...c.count,
|
|
||||||
likes: c.count.likes - 1
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...c,
|
|
||||||
replies
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
commentUnlikeLoading: comment.id
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reportComment({api, data: comment}: {api: GhostApi, data: {id: string}}) {
|
async function reportComment({api, data: comment}: {api: GhostApi, data: {id: string}}) {
|
||||||
|
@ -317,7 +255,6 @@ async function reportComment({api, data: comment}: {api: GhostApi, data: {id: st
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteComment({state, api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
|
async function deleteComment({state, api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
|
||||||
|
|
||||||
await api.comments.edit({
|
await api.comments.edit({
|
||||||
comment: {
|
comment: {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
|
@ -542,8 +479,7 @@ export const Actions = {
|
||||||
highlightComment,
|
highlightComment,
|
||||||
setHighlightComment,
|
setHighlightComment,
|
||||||
setCommentsIsLoading,
|
setCommentsIsLoading,
|
||||||
sendLikeToApi,
|
updateCommentLikeState
|
||||||
sendUnlikeToApi
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActionType = keyof typeof Actions;
|
export type ActionType = keyof typeof Actions;
|
||||||
|
|
|
@ -6,14 +6,15 @@ type Props = {
|
||||||
comment: Comment;
|
comment: Comment;
|
||||||
};
|
};
|
||||||
const LikeButton: React.FC<Props> = ({comment}) => {
|
const LikeButton: React.FC<Props> = ({comment}) => {
|
||||||
const {dispatchAction, member, commentsEnabled, commentLikeLoadingId} = useAppContext();
|
const {dispatchAction, member, commentsEnabled} = useAppContext();
|
||||||
const [animationClass, setAnimation] = useState('');
|
const [animationClass, setAnimation] = useState('');
|
||||||
|
const [disabled, setDisabled] = useState(false);
|
||||||
|
|
||||||
const paidOnly = commentsEnabled === 'paid';
|
const paidOnly = commentsEnabled === 'paid';
|
||||||
const isPaidMember = member && !!member.paid;
|
const isPaidMember = member && !!member.paid;
|
||||||
const canLike = member && (isPaidMember || !paidOnly);
|
const canLike = member && (isPaidMember || !paidOnly);
|
||||||
|
|
||||||
const toggleLike = () => {
|
const toggleLike = async () => {
|
||||||
if (!canLike) {
|
if (!canLike) {
|
||||||
dispatchAction('openPopup', {
|
dispatchAction('openPopup', {
|
||||||
type: 'ctaPopup'
|
type: 'ctaPopup'
|
||||||
|
@ -22,52 +23,27 @@ const LikeButton: React.FC<Props> = ({comment}) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!comment.liked) {
|
if (!comment.liked) {
|
||||||
dispatchAction('likeComment', comment);
|
setDisabled(true);
|
||||||
|
await dispatchAction('likeComment', comment);
|
||||||
setAnimation('animate-heartbeat');
|
setAnimation('animate-heartbeat');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setAnimation('');
|
setAnimation('');
|
||||||
}, 400);
|
}, 400);
|
||||||
|
setDisabled(false);
|
||||||
} else {
|
} else {
|
||||||
dispatchAction('unlikeComment', comment);
|
setDisabled(true);
|
||||||
|
await dispatchAction('unlikeComment', comment);
|
||||||
|
setDisabled(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// If can like: use <button> element, otherwise use a <span>
|
|
||||||
const CustomTag = canLike ? `button` : `span`;
|
|
||||||
|
|
||||||
let likeCursor = 'cursor-pointer';
|
|
||||||
if (!canLike) {
|
|
||||||
likeCursor = 'cursor-text';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (labs && labs.commentImprovements) {
|
|
||||||
const likeIsinLoadingState = commentLikeLoadingId === comment.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`duration-50 group flex cursor-pointer items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${
|
|
||||||
comment.liked ? 'text-black/90 dark:text-white/90' : 'text-black/50 hover:text-black/75 dark:text-white/60 dark:hover:text-white/75'
|
|
||||||
}`}
|
|
||||||
data-testid="like-button"
|
|
||||||
disabled={likeIsinLoadingState}
|
|
||||||
type="button"
|
|
||||||
onClick={toggleLike}
|
|
||||||
>
|
|
||||||
<LikeIcon
|
|
||||||
className={animationClass + ` mr-[6px] ${
|
|
||||||
comment.liked ? 'fill-black dark:fill-white stroke-black dark:stroke-white' : 'stroke-black/50 group-hover:stroke-black/75 dark:stroke-white/60 dark:group-hover:stroke-white/75'
|
|
||||||
} ${!comment.liked && canLike && 'group-hover:stroke-black/75 dark:group-hover:stroke-white/75'} transition duration-50 ease-linear`}
|
|
||||||
/>
|
|
||||||
{comment.count.likes}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`duration-50 group flex cursor-pointer items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${
|
className={`duration-50 group flex cursor-pointer items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${
|
||||||
comment.liked ? 'text-black/90 dark:text-white/90' : 'text-black/50 hover:text-black/75 dark:text-white/60 dark:hover:text-white/75'
|
comment.liked ? 'text-black/90 dark:text-white/90' : 'text-black/50 hover:text-black/75 dark:text-white/60 dark:hover:text-white/75'
|
||||||
}`}
|
}`}
|
||||||
data-testid="like-button"
|
data-testid="like-button"
|
||||||
|
disabled={disabled}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleLike}
|
onClick={toggleLike}
|
||||||
>
|
>
|
||||||
|
|
|
@ -102,12 +102,9 @@ test.describe('Actions', async () => {
|
||||||
html: '<p>This is comment 1</p>'
|
html: '<p>This is comment 1</p>'
|
||||||
});
|
});
|
||||||
mockedApi.addComment({
|
mockedApi.addComment({
|
||||||
html: '<p>This is comment 2</p>',
|
html: '<p>This is comment 2</p>'
|
||||||
liked: true,
|
|
||||||
count: {
|
|
||||||
likes: 52
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
mockedApi.addComment({
|
mockedApi.addComment({
|
||||||
html: '<p>This is comment 3</p>'
|
html: '<p>This is comment 3</p>'
|
||||||
});
|
});
|
||||||
|
@ -136,11 +133,7 @@ test.describe('Actions', async () => {
|
||||||
html: '<p>This is comment 1</p>'
|
html: '<p>This is comment 1</p>'
|
||||||
});
|
});
|
||||||
mockedApi.addComment({
|
mockedApi.addComment({
|
||||||
html: '<p>This is comment 2</p>',
|
html: '<p>This is comment 2</p>'
|
||||||
liked: true,
|
|
||||||
count: {
|
|
||||||
likes: 52
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
mockedApi.addComment({
|
mockedApi.addComment({
|
||||||
html: '<p>This is comment 3</p>'
|
html: '<p>This is comment 3</p>'
|
||||||
|
@ -246,6 +239,39 @@ test.describe('Actions', async () => {
|
||||||
await expect(likeButton).toHaveText('52');
|
await expect(likeButton).toHaveText('52');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Can revert state of a reply if its unsuccessful', async ({page}) => {
|
||||||
|
mockedApi.addComment({
|
||||||
|
html: '<p>This is comment 1</p>',
|
||||||
|
replies: [
|
||||||
|
mockedApi.buildReply({
|
||||||
|
html: '<p>This is a reply to 1</p>',
|
||||||
|
liked: true,
|
||||||
|
count: {
|
||||||
|
likes: 3
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const {frame} = await initializeTest(page);
|
||||||
|
|
||||||
|
// Check like button is not filled yet
|
||||||
|
const comment = frame.getByTestId('comment-component').nth(0);
|
||||||
|
const reply = comment.getByTestId('comment-component').nth(0);
|
||||||
|
// check if reply contains text This is a reply to 1
|
||||||
|
await expect(reply).toContainText('This is a reply to 1');
|
||||||
|
|
||||||
|
const likeButton = reply.getByTestId('like-button');
|
||||||
|
|
||||||
|
mockedApi.setFailure('likeComment', {
|
||||||
|
status: 500,
|
||||||
|
body: {error: 'Internal Server Error'}
|
||||||
|
});
|
||||||
|
await likeButton.click();
|
||||||
|
mockedApi.setDelay(100); // give time for disabled state
|
||||||
|
await expect(likeButton).toHaveText('3');
|
||||||
|
});
|
||||||
|
|
||||||
test('Can reply to a comment', async ({page}) => {
|
test('Can reply to a comment', async ({page}) => {
|
||||||
mockedApi.addComment({
|
mockedApi.addComment({
|
||||||
html: '<p>This is comment 1</p>'
|
html: '<p>This is comment 1</p>'
|
||||||
|
|
Loading…
Add table
Reference in a new issue