diff --git a/apps/comments-ui/src/utils/helpers.test.ts b/apps/comments-ui/src/utils/helpers.test.ts index f8773266fb..095e8eb837 100644 --- a/apps/comments-ui/src/utils/helpers.test.ts +++ b/apps/comments-ui/src/utils/helpers.test.ts @@ -3,6 +3,34 @@ import moment, {DurationInputObject} from 'moment'; import sinon from 'sinon'; import {buildAnonymousMember, buildComment, buildDeletedMember} from '../../test/utils/fixtures'; +/* eslint-disable @typescript-eslint/no-explicit-any */ + +describe('flattenComments', function () { + it('flattens comments and replies', function () { + const comments: any[] = [ + {id: '1', replies: [{id: '2'}]}, + {id: '3', replies: []} + ]; + expect(helpers.flattenComments(comments)).toEqual([ + {id: '1', replies: [{id: '2'}]}, + {id: '2'}, + {id: '3', replies: []} + ]); + }); +}); + +describe('findCommentById', function () { + it('finds a top-level comment', function () { + const comments: any[] = [{id: '1'}, {id: '2'}, {id: '3'}]; + expect(helpers.findCommentById(comments, '2')).toEqual({id: '2'}); + }); + + it('finds a reply', function () { + const comments: any[] = [{id: '1', replies: [{id: '2'}]}, {id: '3'}]; + expect(helpers.findCommentById(comments, '2')).toEqual({id: '2'}); + }); +}); + describe('formatNumber', function () { it('adds commas to large numbers', function () { expect(helpers.formatNumber(1234567)).toEqual('1,234,567'); diff --git a/apps/comments-ui/src/utils/helpers.ts b/apps/comments-ui/src/utils/helpers.ts index 604624bd82..08f001b7fc 100644 --- a/apps/comments-ui/src/utils/helpers.ts +++ b/apps/comments-ui/src/utils/helpers.ts @@ -1,5 +1,14 @@ import {Comment, Member, TranslationFunction} from '../AppContext'; +export function flattenComments(comments: Comment[]): Comment[] { + return comments.flatMap(comment => [comment, ...(comment.replies || [])]); +} + +export function findCommentById(comments: Comment[], id: string): Comment | undefined { + return comments.find(comment => comment?.id === id) + || comments.flatMap(comment => comment.replies || []).find(reply => reply?.id === id); +} + export function formatNumber(number: number): string { if (number !== 0 && !number) { return ''; diff --git a/apps/comments-ui/test/e2e/actions.test.ts b/apps/comments-ui/test/e2e/actions.test.ts index 86861176ca..1556797d4b 100644 --- a/apps/comments-ui/test/e2e/actions.test.ts +++ b/apps/comments-ui/test/e2e/actions.test.ts @@ -1,4 +1,5 @@ import {MockedApi, initialize, waitEditorFocused} from '../utils/e2e'; +import {buildReply} from '../utils/fixtures'; import {expect, test} from '@playwright/test'; test.describe('Actions', async () => { @@ -72,6 +73,29 @@ test.describe('Actions', async () => { await expect(likeButton2).toHaveText('52'); }); + test('Can like and unlike a reply', async ({page}) => { + mockedApi.addComment({ + id: '1', + html: '

This is comment 1

', + replies: [ + buildReply({id: '2', html: '

This is reply 1

'}), + buildReply({id: '3', html: '

This is reply 2

', in_reply_to_id: '2', in_reply_to_snippet: 'This is reply 1'}), + buildReply({id: '4', html: '

This is reply 3

'}) + ] + }); + + const {frame} = await initializeTest(page); + + const reply = frame.getByTestId('comment-component').nth(1); + const likeButton = reply.getByTestId('like-button'); + + await expect(likeButton).toHaveText('0'); + await likeButton.click(); + await expect(likeButton).toHaveText('1'); + await likeButton.click(); + await expect(likeButton).toHaveText('0'); + }); + test('Can reply to a comment', async ({page}) => { mockedApi.addComment({ html: '

This is comment 1

' diff --git a/apps/comments-ui/test/e2e/admin-moderation.test.ts b/apps/comments-ui/test/e2e/admin-moderation.test.ts index 3bddb4eaff..366245c410 100644 --- a/apps/comments-ui/test/e2e/admin-moderation.test.ts +++ b/apps/comments-ui/test/e2e/admin-moderation.test.ts @@ -1,5 +1,6 @@ import sinon from 'sinon'; import {MOCKED_SITE_URL, MockedApi, initialize, mockAdminAuthFrame, mockAdminAuthFrame204} from '../utils/e2e'; +import {buildReply} from '../utils/fixtures'; import {expect, test} from '@playwright/test'; const admin = MOCKED_SITE_URL + '/ghost/'; @@ -169,5 +170,32 @@ test.describe('Admin moderation', async () => { await moreButtons.nth(1).getByText('Show comment').click(); await expect(secondComment).toContainText('This is comment 2'); }); + + test('can hide and show replies', async ({page}) => { + mockedApi.addComment({ + id: '1', + html: '

This is comment 1

', + replies: [ + buildReply({id: '2', html: '

This is reply 1

'}), + buildReply({id: '3', html: '

This is reply 2

'}) + ] + }); + + const {frame} = await initializeTest(page, {labs: true}); + const comments = await frame.getByTestId('comment-component'); + const replyToHide = comments.nth(1); + + // Hide the 1st reply + await replyToHide.getByTestId('more-button').click(); + await replyToHide.getByTestId('hide-button').click(); + + await expect(replyToHide).toContainText('Hidden for members'); + + // Show it again + await replyToHide.getByTestId('more-button').click(); + await replyToHide.getByTestId('show-button').click(); + + await expect(replyToHide).not.toContainText('Hidden for members'); + }); }); }); diff --git a/apps/comments-ui/test/utils/MockedApi.ts b/apps/comments-ui/test/utils/MockedApi.ts index a008b67e39..6f5ccc0dd7 100644 --- a/apps/comments-ui/test/utils/MockedApi.ts +++ b/apps/comments-ui/test/utils/MockedApi.ts @@ -1,5 +1,6 @@ import nql from '@tryghost/nql'; import {buildComment, buildMember, buildReply, buildSettings} from './fixtures'; +import {findCommentById, flattenComments} from '../../src/utils/helpers'; // The test file doesn't run in the browser, so we can't use the DOM API. // We can use a simple regex to strip HTML tags from a string for test purposes. @@ -171,6 +172,13 @@ export class MockedApi { // Parse NQL filter if (filter) { const parsed = nql(filter); + + // When fetching a comment by ID, we need to include all replies so + // the quick way to do that is to flatten the comment+replies array + if (filter.includes('id:')) { + filteredComments = flattenComments(filteredComments); + } + filteredComments = filteredComments.filter((comment) => { return parsed.queryJSON(comment); }); @@ -182,14 +190,13 @@ export class MockedApi { const comments = filteredComments.slice(startIndex, endIndex); return { - comments: comments.map((comment) => { return { ...comment, - replies: comment.replies.slice(0, 3), + replies: comment.replies ? comment.replies?.slice(0, 3) : [], count: { ...comment.count, - replies: comment.replies.length + replies: comment.replies ? comment.replies?.length : 0 } }; }), @@ -341,7 +348,7 @@ export class MockedApi { const url = new URL(route.request().url()); const commentId = url.pathname.split('/').reverse()[2]; - const comment = this.comments.find(c => c.id === commentId); + const comment = flattenComments(this.comments).find(c => c.id === commentId); if (!comment) { return await route.fulfill({ status: 404, @@ -441,14 +448,33 @@ export class MockedApi { }); }, - async updateComment(route) { + async getOrUpdateComment(route) { await this.#delayResponse(); const url = new URL(route.request().url()); + if (route.request().method() === 'GET') { + const commentId = url.pathname.split('/').reverse()[1]; + await route.fulfill({ + status: 200, + body: JSON.stringify(this.browseComments({ + limit: 1, + filter: `id:'${commentId}'`, + page: 1, + order: '', + admin: true + })) + }); + } + if (route.request().method() === 'PUT') { const commentId = url.pathname.split('/').reverse()[1]; const payload = JSON.parse(route.request().postData()); - const comment = this.comments.find(c => c.id === commentId); + const comment = findCommentById(this.comments, commentId); + + if (!comment) { + await route.fulfill({status: 404}); + return; + } comment.status = payload.status; @@ -458,7 +484,8 @@ export class MockedApi { limit: 1, filter: `id:'${commentId}'`, page: 1, - order: '' + order: '', + admin: true })) }); } @@ -479,6 +506,6 @@ export class MockedApi { // Admin API ----------------------------------------------------------- await page.route(`${path}/ghost/api/admin/users/me/`, this.adminRequestHandlers.getUser.bind(this)); await page.route(`${path}/ghost/api/admin/comments/post/*/*`, this.adminRequestHandlers.browseComments.bind(this)); - await page.route(`${path}/ghost/api/admin/comments/*/`, this.adminRequestHandlers.updateComment.bind(this)); + await page.route(`${path}/ghost/api/admin/comments/*/`, this.adminRequestHandlers.getOrUpdateComment.bind(this)); } }