0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -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[]}}) {
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
async function updateCommentLikeState({state, data: comment}: {state: EditableAppContext, data: {id: string, liked: boolean, commentsState: Comment[]}}) {
return {
comments: state.comments.map((c) => {
const replies = c.replies.map((r) => {
if (r.id === comment.id) {
return {
...r,
liked: true,
liked: comment.liked,
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) {
return {
...c,
liked: true,
liked: comment.liked,
replies,
count: {
...c.count,
likes: c.count.likes + 1
likes: comment.liked ? c.count.likes + 1 : c.count.likes - 1
}
};
}
@ -245,71 +223,31 @@ async function likeComment({state, data: comment, dispatchAction}: {state: Edita
...c,
replies
};
}),
commentLikeLoadingId: comment.id
})
};
}
async function sendUnlikeToApi({api, data: comment}: {api: GhostApi, data: {id: string, commentsState: Comment[]}}) {
const commentsState = comment.commentsState;
async function likeComment({state, api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
dispatchAction('updateCommentLikeState', {id: comment.id, liked: true, commentsState: state.comments});
try {
await api.comments.like({comment});
return {};
} catch (err) {
dispatchAction('updateCommentLikeState', {id: comment.id, liked: false, commentsState: state.comments});
}
}
async function unlikeComment({state, api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
dispatchAction('updateCommentLikeState', {id: comment.id, liked: false, commentsState: state.comments});
try {
await api.comments.unlike({comment: {id: comment.id}});
return {
commentUnlikeLoading: null
};
await api.comments.unlike({comment});
return {};
} catch (err) {
// If error, revert the state
return {
comments: commentsState,
commentUnlikeLoading: null
};
dispatchAction('updateCommentLikeState', {id: comment.id, liked: true, commentsState: state.comments});
}
}
async function unlikeComment({state, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
const existingState = state.comments;
dispatchAction('sendUnlikeToApi', {id: comment.id, commentsState: existingState});
// optimistic update
return {
comments: state.comments.map((c) => {
const replies = c.replies.map((r) => {
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}}) {
await api.comments.report({comment});
@ -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}) {
await api.comments.edit({
comment: {
id: comment.id,
@ -542,8 +479,7 @@ export const Actions = {
highlightComment,
setHighlightComment,
setCommentsIsLoading,
sendLikeToApi,
sendUnlikeToApi
updateCommentLikeState
};
export type ActionType = keyof typeof Actions;

View file

@ -6,14 +6,15 @@ type Props = {
comment: Comment;
};
const LikeButton: React.FC<Props> = ({comment}) => {
const {dispatchAction, member, commentsEnabled, commentLikeLoadingId} = useAppContext();
const {dispatchAction, member, commentsEnabled} = useAppContext();
const [animationClass, setAnimation] = useState('');
const [disabled, setDisabled] = useState(false);
const paidOnly = commentsEnabled === 'paid';
const isPaidMember = member && !!member.paid;
const canLike = member && (isPaidMember || !paidOnly);
const toggleLike = () => {
const toggleLike = async () => {
if (!canLike) {
dispatchAction('openPopup', {
type: 'ctaPopup'
@ -22,52 +23,27 @@ const LikeButton: React.FC<Props> = ({comment}) => {
}
if (!comment.liked) {
dispatchAction('likeComment', comment);
setDisabled(true);
await dispatchAction('likeComment', comment);
setAnimation('animate-heartbeat');
setTimeout(() => {
setAnimation('');
}, 400);
setDisabled(false);
} 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 (
<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={disabled}
type="button"
onClick={toggleLike}
>

View file

@ -102,12 +102,9 @@ test.describe('Actions', async () => {
html: '<p>This is comment 1</p>'
});
mockedApi.addComment({
html: '<p>This is comment 2</p>',
liked: true,
count: {
likes: 52
}
html: '<p>This is comment 2</p>'
});
mockedApi.addComment({
html: '<p>This is comment 3</p>'
});
@ -136,11 +133,7 @@ test.describe('Actions', async () => {
html: '<p>This is comment 1</p>'
});
mockedApi.addComment({
html: '<p>This is comment 2</p>',
liked: true,
count: {
likes: 52
}
html: '<p>This is comment 2</p>'
});
mockedApi.addComment({
html: '<p>This is comment 3</p>'
@ -246,6 +239,39 @@ test.describe('Actions', async () => {
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}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>'