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:
parent
978609506a
commit
8db228fa6e
5 changed files with 284 additions and 51 deletions
|
@ -82,7 +82,7 @@ export type EditableAppContext = {
|
|||
labs: LabsContextType,
|
||||
order: string,
|
||||
adminApi: AdminApi | null,
|
||||
commentsIsLoading?: boolean
|
||||
commentsIsLoading?: boolean,
|
||||
commentIdToHighlight: string | null
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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>'
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue