0
Fork 0
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:
Ronald Langeveld 2024-12-13 14:05:13 +08:00
parent c0071db5b2
commit 76d08f1696
3 changed files with 66 additions and 128 deletions

View file

@ -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;

View file

@ -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}
> >

View file

@ -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>'