0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-04 02:01:58 -05:00

Updated Comments likes UI changes to be instant (#21861)

ref PLG-288

- Implemented instant UI updates for comment likes/unlikes using
optimistic rendering.
- Enhanced error handling reverts state on API failure, ensuring
consistency.
- Added new test helper to mock failed API requests, needed to test
revert state handling.
This commit is contained in:
Ronald Langeveld 2025-01-14 15:20:47 +09:00 committed by GitHub
parent 978609506a
commit 8db228fa6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 284 additions and 51 deletions

View file

@ -82,7 +82,7 @@ export type EditableAppContext = {
labs: LabsContextType,
order: string,
adminApi: AdminApi | null,
commentsIsLoading?: boolean
commentsIsLoading?: boolean,
commentIdToHighlight: string | null
}

View file

@ -189,19 +189,17 @@ async function showComment({state, api, data: comment}: {state: EditableAppConte
};
}
async function likeComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: {id: string}}) {
await api.comments.like({comment});
async function updateCommentLikeState({state, data: comment}: {state: EditableAppContext, data: {id: string, liked: boolean}}) {
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
}
};
}
@ -212,11 +210,11 @@ async function likeComment({state, api, data: comment}: {state: EditableAppConte
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
}
};
}
@ -229,51 +227,33 @@ async function likeComment({state, api, data: comment}: {state: EditableAppConte
};
}
async function likeComment({api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
dispatchAction('updateCommentLikeState', {id: comment.id, liked: true});
try {
await api.comments.like({comment});
return {};
} catch (err) {
dispatchAction('updateCommentLikeState', {id: comment.id, liked: false});
}
}
async function unlikeComment({api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
dispatchAction('updateCommentLikeState', {id: comment.id, liked: false});
try {
await api.comments.unlike({comment});
return {};
} catch (err) {
dispatchAction('updateCommentLikeState', {id: comment.id, liked: true});
}
}
async function reportComment({api, data: comment}: {api: GhostApi, data: {id: string}}) {
await api.comments.report({comment});
return {};
}
async function unlikeComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: {id: string}}) {
await api.comments.unlike({comment});
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
};
})
};
}
async function deleteComment({state, api, data: comment, dispatchAction}: {state: EditableAppContext, api: GhostApi, data: {id: string}, dispatchAction: DispatchActionType}) {
await api.comments.edit({
comment: {
@ -498,7 +478,8 @@ export const Actions = {
openCommentForm,
highlightComment,
setHighlightComment,
setCommentsIsLoading
setCommentsIsLoading,
updateCommentLikeState
};
export type ActionType = keyof typeof Actions;

View file

@ -8,12 +8,13 @@ type Props = {
const LikeButton: React.FC<Props> = ({comment}) => {
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,13 +23,17 @@ 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);
}
};
@ -38,6 +43,7 @@ const LikeButton: React.FC<Props> = ({comment}) => {
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

@ -1,3 +1,4 @@
import sinon from 'sinon';
import {MockedApi, initialize, waitEditorFocused} from '../utils/e2e';
import {buildMember, buildReply} from '../utils/fixtures';
import {expect, test} from '@playwright/test';
@ -96,6 +97,181 @@ test.describe('Actions', async () => {
await expect(likeButton).toHaveText('0');
});
test('Like button is disabled while api is loading, UI updates instantly', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>'
});
mockedApi.addComment({
html: '<p>This is comment 2</p>'
});
mockedApi.addComment({
html: '<p>This is comment 3</p>'
});
const memberLikeSpy = sinon.spy(mockedApi.requestHandlers, 'likeComment');
const {frame} = await initializeTest(page);
// Check like button is not filled yet
const comment = frame.getByTestId('comment-component').nth(0);
const likeButton = comment.getByTestId('like-button');
await expect(likeButton).toHaveCount(1);
const icon = likeButton.locator('svg');
await expect(icon).not.toHaveClass(/fill/);
await expect(likeButton).toHaveText('0');
await likeButton.click();
mockedApi.setDelay(100); // give time for disabled state
await expect(likeButton).toHaveText('1');
expect(likeButton.isDisabled()).toBeTruthy();
expect(memberLikeSpy.called).toBe(true);
});
test('Like state reverts when like api request is unsuccessful', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>'
});
mockedApi.addComment({
html: '<p>This is comment 2</p>'
});
mockedApi.addComment({
html: '<p>This is comment 3</p>'
});
const {frame} = await initializeTest(page);
// Check like button is not filled yet
const comment = frame.getByTestId('comment-component').nth(0);
const likeButton = comment.getByTestId('like-button');
await expect(likeButton).toHaveText('0');
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('0');
});
test('like button UI updates instantly when unliking a comment', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>',
liked: true,
count: {
likes: 52
}
});
const memberLikeSpy = sinon.spy(mockedApi.requestHandlers, 'likeComment');
const {frame} = await initializeTest(page);
// Check like button is filled
const comment = frame.getByTestId('comment-component').nth(0);
const likeButton = comment.getByTestId('like-button');
await expect(likeButton).toHaveText('52');
const icon = likeButton.locator('svg');
await expect(icon).toHaveClass(/fill/);
await likeButton.click();
mockedApi.setDelay(100); // give time for disabled state
await expect(likeButton).toHaveText('51');
expect(likeButton.isDisabled()).toBeTruthy();
expect(memberLikeSpy.called).toBe(true);
});
test('like button UI updates instantly when unliking a comment and can like again after button is enabled', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>',
liked: true,
count: {
likes: 52
}
});
const memberLikeSpy = sinon.spy(mockedApi.requestHandlers, 'likeComment');
const {frame} = await initializeTest(page);
// Check like button is filled
const comment = frame.getByTestId('comment-component').nth(0);
const likeButton = comment.getByTestId('like-button');
await expect(likeButton).toHaveText('52');
const icon = likeButton.locator('svg');
await expect(icon).toHaveClass(/fill/);
await likeButton.click();
mockedApi.setDelay(100); // give time for disabled state
await expect(likeButton).toHaveText('51');
expect(likeButton.isDisabled()).toBeTruthy();
expect(memberLikeSpy.called).toBe(true);
expect(await likeButton.isDisabled()).toBeFalsy();
await likeButton.click();
await expect(likeButton).toHaveText('52');
});
test('Like state reverts when unlike api request is unsuccessful', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>',
liked: true,
count: {
likes: 52
}
});
const {frame} = await initializeTest(page);
// Check like button is filled
const comment = frame.getByTestId('comment-component').nth(0);
const likeButton = comment.getByTestId('like-button');
await expect(likeButton).toHaveText('52');
const icon = likeButton.locator('svg');
await expect(icon).toHaveClass(/fill/);
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('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>'

View file

@ -20,6 +20,8 @@ export class MockedApi {
labs: any;
failures: Map<string, {status: number, body: any}>;
#lastCommentDate = new Date('2021-01-01T00:00:00.000Z');
#findReplyById(id: string) {
@ -34,12 +36,22 @@ export class MockedApi {
this.members = [];
this.delay = 0;
this.labs = labs;
this.failures = new Map();
}
setDelay(delay: number) {
this.delay = delay;
}
setFailure(handlerName: string, failureResponse: {status: number, body: any}) {
if (!this.requestHandlers[handlerName] && !this.adminRequestHandlers[handlerName]) {
throw new Error(`Handler ${handlerName} does not exist in MockedApi`);
}
const key = handlerName;
this.failures.set(key, failureResponse);
}
addComment(overrides: any = {}) {
if (!overrides.created_at) {
overrides.created_at = this.#lastCommentDate.toISOString();
@ -265,11 +277,25 @@ export class MockedApi {
});
}
async #handleFailure(handlerName: string) {
if (this.failures.has(handlerName)) {
const failure = this.failures.get(handlerName);
return {
status: failure?.status,
body: JSON.stringify(failure?.body)
};
}
}
// Request handlers ------------------------------------------------------
// (useful to spy on these methods in tests)
requestHandlers = {
async getMember(route) {
const failureResponse = await this.#handleFailure('getMember');
if (failureResponse) {
return route.fulfill(failureResponse);
}
await this.#delayResponse();
if (!this.member) {
return await route.fulfill({
@ -293,6 +319,10 @@ export class MockedApi {
},
async addComment(route) {
const failureResponse = await this.#handleFailure('addComment');
if (failureResponse) {
return route.fulfill(failureResponse);
}
await this.#delayResponse();
const payload = JSON.parse(route.request().postData());
@ -312,6 +342,10 @@ export class MockedApi {
},
async browseComments(route) {
const failureResponse = await this.#handleFailure('browseComments');
if (failureResponse) {
return route.fulfill(failureResponse);
}
await this.#delayResponse();
const url = new URL(route.request().url());
@ -331,6 +365,10 @@ export class MockedApi {
},
async getOrDeleteComment(route) {
const failureResponse = await this.#handleFailure('getComment');
if (failureResponse) {
return route.fulfill(failureResponse);
}
await this.#delayResponse();
const url = new URL(route.request().url());
const commentId = url.pathname.split('/').reverse()[1];
@ -354,6 +392,10 @@ export class MockedApi {
},
async likeComment(route) {
const failureResponse = await this.#handleFailure('likeComment');
if (failureResponse) {
return route.fulfill(failureResponse);
}
await this.#delayResponse();
const url = new URL(route.request().url());
const commentId = url.pathname.split('/').reverse()[2];
@ -388,6 +430,10 @@ export class MockedApi {
},
async getReplies(route) {
const failureResponse = await this.#handleFailure('getReplies');
if (failureResponse) {
return route.fulfill(failureResponse);
}
await this.#delayResponse();
const url = new URL(route.request().url());
@ -406,6 +452,10 @@ export class MockedApi {
},
async getCommentCounts(route) {
const failureResponse = await this.#handleFailure('getCommentCounts');
if (failureResponse) {
return route.fulfill(failureResponse);
}
await this.#delayResponse();
await route.fulfill({
status: 200,
@ -416,6 +466,10 @@ export class MockedApi {
},
async getSettings(route) {
const failureResponse = await this.#handleFailure('getSettings');
if (failureResponse) {
return route.fulfill(failureResponse);
}
await this.#delayResponse();
await route.fulfill({
status: 200,
@ -426,6 +480,10 @@ export class MockedApi {
adminRequestHandlers = {
async getUser(route) {
const failureResponse = await this.#handleFailure('getUser');
if (failureResponse) {
return route.fulfill(failureResponse);
}
await this.#delayResponse();
await route.fulfill({
status: 200,
@ -438,6 +496,10 @@ export class MockedApi {
},
async browseComments(route) {
const failureResponse = await this.#handleFailure('browseComments');
if (failureResponse) {
return route.fulfill(failureResponse);
}
await this.#delayResponse();
const url = new URL(route.request().url());
@ -461,6 +523,10 @@ export class MockedApi {
},
async getReplies(route) {
const failureResponse = await this.#handleFailure('getReplies');
if (failureResponse) {
return route.fulfill(failureResponse);
}
await this.#delayResponse();
const url = new URL(route.request().url());
@ -481,6 +547,10 @@ export class MockedApi {
},
async getOrUpdateComment(route) {
const failureResponse = await this.#handleFailure('getOrUpdateComment');
if (failureResponse) {
return route.fulfill(failureResponse);
}
await this.#delayResponse();
const url = new URL(route.request().url());