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

Added reply button to comment replies

closes https://linear.app/tryghost/issue/PLG-221

- added `toggleParentReplyMode()` to comment component's props so clicking Reply on a reply opens the reply form on the top-level parent to emphasise we only support 1-level deep replies and avoid unexpected nesting
- adjusted conditional so "Reply" button is shown in `<CommentMenu>` when a parent is present (behind labs flag)
- updated `useLabs()` to always return an object so we don't need to add nullish checks everywhere

# Conflicts:
#	apps/comments-ui/test/e2e/actions.test.ts
This commit is contained in:
Kevin Ansfield 2024-09-25 12:22:51 +01:00
parent 7f6517fbe8
commit 2bca5efcec
4 changed files with 94 additions and 27 deletions

View file

@ -67,7 +67,7 @@ export type EditableAppContext = {
commentCount: number, commentCount: number,
secundaryFormCount: number, secundaryFormCount: number,
popup: Page | null, popup: Page | null,
labs: LabsContextType | null labs: LabsContextType
} }
export type TranslationFunction = (key: string, replacements?: Record<string, string | number>) => string; export type TranslationFunction = (key: string, replacements?: Record<string, string | number>) => string;
@ -92,8 +92,8 @@ export const useAppContext = () => useContext(AppContext);
export const useLabs = () => { export const useLabs = () => {
try { try {
const context = useAppContext(); const context = useAppContext();
return context.labs; return context.labs || {};
} catch (e) { } catch (e) {
return null; return {};
} }
}; };

View file

@ -1,11 +1,11 @@
import EditForm from './forms/EditForm'; import EditForm from './forms/EditForm';
import LikeButton from './buttons/LikeButton'; import LikeButton from './buttons/LikeButton';
import MoreButton from './buttons/MoreButton'; import MoreButton from './buttons/MoreButton';
import Replies from './Replies'; import Replies, {RepliesProps} from './Replies';
import ReplyButton from './buttons/ReplyButton'; import ReplyButton from './buttons/ReplyButton';
import ReplyForm from './forms/ReplyForm'; import ReplyForm from './forms/ReplyForm';
import {Avatar, BlankAvatar} from './Avatar'; import {Avatar, BlankAvatar} from './Avatar';
import {Comment, useAppContext} from '../../AppContext'; import {Comment, useAppContext, useLabs} from '../../AppContext';
import {Transition} from '@headlessui/react'; import {Transition} from '@headlessui/react';
import {formatExplicitTime, isCommentPublished} from '../../utils/helpers'; import {formatExplicitTime, isCommentPublished} from '../../utils/helpers';
import {useRelativeTime} from '../../utils/hooks'; import {useRelativeTime} from '../../utils/hooks';
@ -14,9 +14,10 @@ import {useState} from 'react';
type AnimatedCommentProps = { type AnimatedCommentProps = {
comment: Comment; comment: Comment;
parent?: Comment; parent?: Comment;
toggleParentReplyMode?: () => Promise<void>;
}; };
const AnimatedComment: React.FC<AnimatedCommentProps> = ({comment, parent}) => { const AnimatedComment: React.FC<AnimatedCommentProps> = ({comment, parent, toggleParentReplyMode}) => {
return ( return (
<Transition <Transition
enter="transition-opacity duration-300 ease-out" enter="transition-opacity duration-300 ease-out"
@ -28,13 +29,13 @@ const AnimatedComment: React.FC<AnimatedCommentProps> = ({comment, parent}) => {
show={true} show={true}
appear appear
> >
<EditableComment comment={comment} parent={parent} /> <EditableComment comment={comment} parent={parent} toggleParentReplyMode={toggleParentReplyMode} />
</Transition> </Transition>
); );
}; };
type EditableCommentProps = AnimatedCommentProps; type EditableCommentProps = AnimatedCommentProps;
const EditableComment: React.FC<EditableCommentProps> = ({comment, parent}) => { const EditableComment: React.FC<EditableCommentProps> = ({comment, parent, toggleParentReplyMode}) => {
const [isInEditMode, setIsInEditMode] = useState(false); const [isInEditMode, setIsInEditMode] = useState(false);
const closeEditMode = () => { const closeEditMode = () => {
@ -50,27 +51,31 @@ const EditableComment: React.FC<EditableCommentProps> = ({comment, parent}) => {
<EditForm close={closeEditMode} comment={comment} parent={parent} /> <EditForm close={closeEditMode} comment={comment} parent={parent} />
); );
} else { } else {
return (<CommentComponent comment={comment} openEditMode={openEditMode} parent={parent} />); return (<CommentComponent comment={comment} openEditMode={openEditMode} parent={parent} toggleParentReplyMode={toggleParentReplyMode} />);
} }
}; };
type CommentProps = AnimatedCommentProps & { type CommentProps = AnimatedCommentProps & {
openEditMode: () => void; openEditMode: () => void;
}; };
const CommentComponent: React.FC<CommentProps> = ({comment, parent, openEditMode}) => { const CommentComponent: React.FC<CommentProps> = ({comment, parent, openEditMode, toggleParentReplyMode}) => {
const isPublished = isCommentPublished(comment); const isPublished = isCommentPublished(comment);
if (isPublished) { if (isPublished) {
return (<PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} />); return (<PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} toggleParentReplyMode={toggleParentReplyMode} />);
} }
return (<UnpublishedComment comment={comment} openEditMode={openEditMode} />); return (<UnpublishedComment comment={comment} openEditMode={openEditMode} />);
}; };
const PublishedComment: React.FC<CommentProps> = ({comment, parent, openEditMode}) => { const PublishedComment: React.FC<CommentProps> = ({comment, parent, openEditMode, toggleParentReplyMode}) => {
const [isInReplyMode, setIsInReplyMode] = useState(false); const [isInReplyMode, setIsInReplyMode] = useState(false);
const {dispatchAction} = useAppContext(); const {dispatchAction} = useAppContext();
const toggleReplyMode = async () => { const toggleReplyMode = async () => {
if (parent && toggleParentReplyMode) {
return await toggleParentReplyMode();
}
if (!isInReplyMode) { if (!isInReplyMode) {
// First load all the replies before opening the reply model // First load all the replies before opening the reply model
await dispatchAction('loadMoreReplies', {comment, limit: 'all'}); await dispatchAction('loadMoreReplies', {comment, limit: 'all'});
@ -91,7 +96,7 @@ const PublishedComment: React.FC<CommentProps> = ({comment, parent, openEditMode
<CommentBody html={comment.html} /> <CommentBody html={comment.html} />
<CommentMenu comment={comment} isInReplyMode={isInReplyMode} openEditMode={openEditMode} parent={parent} toggleReplyMode={toggleReplyMode} /> <CommentMenu comment={comment} isInReplyMode={isInReplyMode} openEditMode={openEditMode} parent={parent} toggleReplyMode={toggleReplyMode} />
<RepliesContainer comment={comment} /> <RepliesContainer comment={comment} toggleReplyMode={toggleReplyMode} />
<ReplyFormBox closeReplyMode={closeReplyMode} comment={comment} isInReplyMode={isInReplyMode} /> <ReplyFormBox closeReplyMode={closeReplyMode} comment={comment} isInReplyMode={isInReplyMode} />
</CommentLayout> </CommentLayout>
); );
@ -156,7 +161,7 @@ const EditedInfo: React.FC<{comment: Comment}> = ({comment}) => {
); );
}; };
const RepliesContainer: React.FC<{comment: Comment}> = ({comment}) => { const RepliesContainer: React.FC<RepliesProps> = ({comment, toggleReplyMode}) => {
const hasReplies = comment.replies && comment.replies.length > 0; const hasReplies = comment.replies && comment.replies.length > 0;
if (!hasReplies) { if (!hasReplies) {
@ -165,7 +170,7 @@ const RepliesContainer: React.FC<{comment: Comment}> = ({comment}) => {
return ( return (
<div className="mb-4 ml-[-1.4rem] mt-7 sm:mb-0 sm:mt-8"> <div className="mb-4 ml-[-1.4rem] mt-7 sm:mb-0 sm:mt-8">
<Replies comment={comment} /> <Replies comment={comment} toggleReplyMode={toggleReplyMode} />
</div> </div>
); );
}; };
@ -241,10 +246,11 @@ const CommentMenu: React.FC<CommentMenuProps> = ({comment, toggleReplyMode, isIn
// If this comment is from the current member, always override member // If this comment is from the current member, always override member
// with the member from the context, so we update the expertise in existing comments when we change it // with the member from the context, so we update the expertise in existing comments when we change it
const {member, commentsEnabled} = useAppContext(); const {member, commentsEnabled} = useAppContext();
const labs = useLabs();
const paidOnly = commentsEnabled === 'paid'; const paidOnly = commentsEnabled === 'paid';
const isPaidMember = member && !!member.paid; const isPaidMember = member && !!member.paid;
const canReply = member && (isPaidMember || !paidOnly) && !parent; const canReply = member && (isPaidMember || !paidOnly) && (labs.commentImprovements ? true : !parent);
return ( return (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

View file

@ -2,10 +2,11 @@ import CommentComponent from './Comment';
import RepliesPagination from './RepliesPagination'; import RepliesPagination from './RepliesPagination';
import {Comment, useAppContext} from '../../AppContext'; import {Comment, useAppContext} from '../../AppContext';
type Props = { export type RepliesProps = {
comment: Comment comment: Comment,
toggleReplyMode?: () => Promise<void>
}; };
const Replies: React.FC<Props> = ({comment}) => { const Replies: React.FC<RepliesProps> = ({comment, toggleReplyMode}) => {
const {dispatchAction} = useAppContext(); const {dispatchAction} = useAppContext();
const repliesLeft = comment.count.replies - comment.replies.length; const repliesLeft = comment.count.replies - comment.replies.length;
@ -16,7 +17,7 @@ const Replies: React.FC<Props> = ({comment}) => {
return ( return (
<div> <div>
{comment.replies.map((reply => <CommentComponent key={reply.id} comment={reply} parent={comment} />))} {comment.replies.map((reply => <CommentComponent key={reply.id} comment={reply} parent={comment} toggleParentReplyMode={toggleReplyMode} />))}
{repliesLeft > 0 && <RepliesPagination count={repliesLeft} loadMore={loadMore}/>} {repliesLeft > 0 && <RepliesPagination count={repliesLeft} loadMore={loadMore}/>}
</div> </div>
); );

View file

@ -2,10 +2,14 @@ import {MockedApi, initialize, waitEditorFocused} from '../utils/e2e';
import {expect, test} from '@playwright/test'; import {expect, test} from '@playwright/test';
test.describe('Actions', async () => { test.describe('Actions', async () => {
test('Can like and unlike a comment', async ({page}) => { let mockedApi: MockedApi;
const mockedApi = new MockedApi({});
mockedApi.setMember({});
test.beforeEach(async () => {
mockedApi = new MockedApi({});
mockedApi.setMember({});
});
test('Can like and unlike a comment', async ({page}) => {
mockedApi.addComment({ mockedApi.addComment({
html: '<p>This is comment 1</p>' html: '<p>This is comment 1</p>'
}); });
@ -58,9 +62,6 @@ test.describe('Actions', async () => {
}); });
test('Can reply to a comment', async ({page}) => { test('Can reply to a comment', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.setMember({});
mockedApi.addComment({ mockedApi.addComment({
html: '<p>This is comment 1</p>' html: '<p>This is comment 1</p>'
}); });
@ -106,8 +107,67 @@ test.describe('Actions', async () => {
await expect(frame.getByText('This is a reply 123')).toHaveCount(1); await expect(frame.getByText('This is a reply 123')).toHaveCount(1);
}); });
test('Reply-to-reply action not shown without labs flag', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>',
replies: [
mockedApi.buildReply({
html: '<p>This is a reply to 1</p>'
})
]
});
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly'
});
const parentComment = frame.getByTestId('comment-component').nth(0);
const replyComment = parentComment.getByTestId('comment-component').nth(0);
expect(replyComment.getByTestId('reply-button')).not.toBeVisible();
});
test('Can reply to a reply', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>',
replies: [
mockedApi.buildReply({
html: '<p>This is a reply to 1</p>'
})
]
});
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly',
labs: {
commentImprovements: true
}
});
const parentComment = frame.getByTestId('comment-component').nth(0);
const replyComment = parentComment.getByTestId('comment-component').nth(0);
const replyReplyButton = replyComment.getByTestId('reply-button');
await replyReplyButton.click();
const editor = frame.getByTestId('form-editor');
await expect(editor).toBeVisible();
await waitEditorFocused(editor);
await page.keyboard.type('This is a reply to a reply');
const submitButton = parentComment.getByTestId('submit-form-button');
await submitButton.click();
await expect(frame.getByTestId('comment-component')).toHaveCount(3);
await expect(frame.getByText('This is a reply to a reply')).toHaveCount(1);
});
test('Can add expertise', async ({page}) => { test('Can add expertise', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.setMember({name: 'John Doe', expertise: null}); mockedApi.setMember({name: 'John Doe', expertise: null});
mockedApi.addComment({ mockedApi.addComment({