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:
parent
7f6517fbe8
commit
2bca5efcec
4 changed files with 94 additions and 27 deletions
|
@ -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 {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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({
|
||||||
|
|
Loading…
Add table
Reference in a new issue