diff --git a/apps/comments-ui/src/components/content/Avatar.tsx b/apps/comments-ui/src/components/content/Avatar.tsx index d3c864f859e..f60fa0fb25f 100644 --- a/apps/comments-ui/src/components/content/Avatar.tsx +++ b/apps/comments-ui/src/components/content/Avatar.tsx @@ -1,5 +1,5 @@ import {ReactComponent as AvatarIcon} from '../../images/icons/avatar.svg'; -import {Comment, useAppContext} from '../../AppContext'; +import {Comment, Member, useAppContext} from '../../AppContext'; import {getInitials, getMemberInitialsFromComment} from '../../utils/helpers'; function getDimensionClasses() { @@ -19,13 +19,15 @@ export const BlankAvatar = () => { type AvatarProps = { comment?: Comment; + member?: Member; }; -export const Avatar: React.FC = ({comment}) => { - // #TODO greyscale the avatar image when it's hidden - const {member, avatarSaturation, t} = useAppContext(); + +export const Avatar: React.FC = ({comment, member: propMember}) => { + const {member: contextMember, avatarSaturation, t} = useAppContext(); const dimensionClasses = getDimensionClasses(); - const memberName = member?.name ?? comment?.member?.name; + const activeMember = propMember || comment?.member || contextMember; + const memberName = activeMember?.name; const getHashOfString = (str: string) => { let hash = 0; @@ -41,9 +43,7 @@ export const Avatar: React.FC = ({comment}) => { }; const generateHSL = (): [number, number, number] => { - const commentMember = (comment ? comment.member : member); - - if (!commentMember || !commentMember.name) { + if (!activeMember || !activeMember.name) { return [0,0,10]; } @@ -54,7 +54,7 @@ export const Avatar: React.FC = ({comment}) => { const lRangeBottom = lRangeTop - 20; const lRange = [lRangeBottom, lRangeTop]; - const hash = getHashOfString(commentMember.name); + const hash = getHashOfString(activeMember.name); const h = normalizeHash(hash, hRange[0], hRange[1]); const l = normalizeHash(hash, lRange[0], lRange[1]); @@ -66,8 +66,7 @@ export const Avatar: React.FC = ({comment}) => { }; const memberInitials = (comment && getMemberInitialsFromComment(comment, t)) || - (member && getInitials(member.name || '')) || ''; - const commentMember = (comment ? comment.member : member); + (activeMember && getInitials(activeMember.name || '')) || ''; const bgColor = HSLtoString(generateHSL()); const avatarStyle = { @@ -83,7 +82,7 @@ export const Avatar: React.FC = ({comment}) => { (
)} - {commentMember && Avatar} + {activeMember?.avatar_image && Avatar} ); diff --git a/apps/comments-ui/src/components/content/Comment.tsx b/apps/comments-ui/src/components/content/Comment.tsx index 8009183d7f9..b320856ddf8 100644 --- a/apps/comments-ui/src/components/content/Comment.tsx +++ b/apps/comments-ui/src/components/content/Comment.tsx @@ -30,23 +30,36 @@ const AnimatedComment: React.FC = ({comment, parent}) => { show={true} appear > - + ); }; -type EditableCommentProps = AnimatedCommentProps; -const EditableComment: React.FC = ({comment, parent}) => { - const {openCommentForms} = useAppContext(); +export const CommentComponent: React.FC = ({comment, parent}) => { + const {dispatchAction, admin} = useAppContext(); + const labs = useLabs(); + const {showDeletedMessage, showHiddenMessage, showCommentContent} = useCommentVisibility(comment, admin, labs); - const form = openCommentForms.find(openForm => openForm.id === comment.id && openForm.type === 'edit'); - const isInEditMode = !!form; + const openEditMode = useCallback(() => { + const newForm: OpenCommentForm = { + id: comment.id, + type: 'edit', + hasUnsavedChanges: false, + in_reply_to_id: comment.in_reply_to_id, + in_reply_to_snippet: comment.in_reply_to_snippet + }; + dispatchAction('openCommentForm', newForm); + }, [comment.id, dispatchAction]); - if (isInEditMode) { - return (); - } else { - return (); + if (showDeletedMessage) { + return ; + } else if (showCommentContent && !showHiddenMessage) { + return ; + } else if (!labs.commentImprovements && comment.status !== 'published' || showHiddenMessage) { + return ; } + + return null; }; type CommentProps = AnimatedCommentProps; @@ -74,33 +87,6 @@ const useCommentVisibility = (comment: Comment, admin: boolean, labs: {commentIm }; }; -export const CommentComponent: React.FC = ({comment, parent}) => { - const {dispatchAction, admin} = useAppContext(); - const labs = useLabs(); - const {showDeletedMessage, showHiddenMessage, showCommentContent} = useCommentVisibility(comment, admin, labs); - - const openEditMode = useCallback(() => { - const newForm: OpenCommentForm = { - id: comment.id, - type: 'edit', - hasUnsavedChanges: false, - in_reply_to_id: comment.in_reply_to_id, - in_reply_to_snippet: comment.in_reply_to_snippet - }; - dispatchAction('openCommentForm', newForm); - }, [comment.id, dispatchAction]); - - if (showDeletedMessage) { - return ; - } else if (showCommentContent && !showHiddenMessage) { - return ; - } else if (!labs.commentImprovements && comment.status !== 'published' || showHiddenMessage) { - return ; - } - - return null; -}; - type PublishedCommentProps = CommentProps & { openEditMode: () => void; } @@ -112,6 +98,10 @@ const PublishedComment: React.FC = ({comment, parent, ope const isHidden = labs.commentImprovements && admin && comment.status === 'hidden'; const hiddenClass = isHidden ? 'opacity-30' : ''; + // Check if this comment is being edited + const editForm = openCommentForms.find(openForm => openForm.id === comment.id && openForm.type === 'edit'); + const isInEditMode = !!editForm; + // currently a reply-to-reply form is displayed inside the top-level PublishedComment component // so we need to check for a match of either the comment id or the parent id const openForm = openCommentForms.find(f => (f.id === comment.id || f.parent_id === comment.id) && f.type === 'reply'); @@ -148,15 +138,26 @@ const PublishedComment: React.FC = ({comment, parent, ope return ( - - - +
+ {isInEditMode ? ( + <> + + + + ) : ( + <> + + + + + )} +
{displayReplyForm && }
diff --git a/apps/comments-ui/src/components/content/forms/EditForm.tsx b/apps/comments-ui/src/components/content/forms/EditForm.tsx index eba16be8995..970a3f7207e 100644 --- a/apps/comments-ui/src/components/content/forms/EditForm.tsx +++ b/apps/comments-ui/src/components/content/forms/EditForm.tsx @@ -1,5 +1,5 @@ -import Form from './Form'; import {Comment, OpenCommentForm, useAppContext} from '../../../AppContext'; +import {Form} from './Form'; import {getEditorConfig} from '../../../utils/editor'; import {isMobile} from '../../../utils/helpers'; import {useCallback, useEffect} from 'react'; @@ -60,20 +60,18 @@ const EditForm: React.FC = ({comment, openForm, parent}) => { }, [dispatchAction, openForm]); return ( -
-
-
-
+
+
); }; diff --git a/apps/comments-ui/src/components/content/forms/Form.tsx b/apps/comments-ui/src/components/content/forms/Form.tsx index b81db273c89..3f81a20eefc 100644 --- a/apps/comments-ui/src/components/content/forms/Form.tsx +++ b/apps/comments-ui/src/components/content/forms/Form.tsx @@ -8,9 +8,9 @@ import {Transition} from '@headlessui/react'; import {useCallback, useEffect, useRef, useState} from 'react'; import {usePopupOpen} from '../../../utils/hooks'; -type Progress = 'default' | 'sending' | 'sent' | 'error'; +export type Progress = 'default' | 'sending' | 'sent' | 'error'; export type SubmitSize = 'small' | 'medium' | 'large'; -type FormEditorProps = { +export type FormEditorProps = { comment?: Comment; submit: (data: {html: string}) => Promise; progress: Progress; @@ -22,12 +22,14 @@ type FormEditorProps = { submitText: React.ReactNode; submitSize: SubmitSize; openForm?: OpenCommentForm; + initialHasContent?: boolean; }; -const FormEditor: React.FC = ({comment, submit, progress, setProgress, close, reduced, isOpen, editor, submitText, submitSize, openForm}) => { + +export const FormEditor: React.FC = ({comment, submit, progress, setProgress, close, isOpen, editor, submitText, submitSize, openForm, initialHasContent}) => { const labs = useLabs(); const {dispatchAction, t} = useAppContext(); let buttonIcon = null; - const [hasContent, setHasContent] = useState(false); + const [hasContent, setHasContent] = useState(initialHasContent || false); useEffect(() => { if (editor) { @@ -132,17 +134,10 @@ const FormEditor: React.FC = ({comment, submit, progress, setPr }; }, [editor, close, submitForm]); - let openStyles = ''; - if (isOpen) { - const isReplyToReply = labs.commentImprovements && !!openForm?.in_reply_to_snippet; - openStyles = isReplyToReply ? 'pl-[1px] pt-[68px] sm:pl-[44px] sm:pt-[56px]' : 'pl-[1px] pt-[48px] sm:pl-[44px] sm:pt-[40px]'; - } - return ( -
+ <>
= ({comment, submit, progress, setPr )}
-
+ ); }; @@ -236,7 +231,6 @@ const FormHeader: React.FC = ({show, name, expertise, replyingT }; type FormProps = { - openForm: OpenCommentForm; comment?: Comment; editor: Editor | null; submit: (data: {html: string}) => Promise; @@ -245,16 +239,29 @@ type FormProps = { close?: () => void; isOpen: boolean; reduced: boolean; + openForm?: OpenCommentForm; }; -const Form: React.FC = ({comment, submit, submitText, submitSize, close, editor, reduced, isOpen, openForm}) => { - const {member, dispatchAction} = useAppContext(); +const Form: React.FC = ({ + comment, + submit, + submitText, + submitSize, + close, + editor, + reduced, + isOpen, + openForm +}) => { + const {member} = useAppContext(); const isAskingDetails = usePopupOpen('addDetailsPopup'); const [progress, setProgress] = useState('default'); const formEl = useRef(null); + // Initialize hasContent to true if we're editing an existing comment + const initialHasContent = openForm?.type === 'edit' && !!comment?.html; + const memberName = member?.name ?? comment?.member?.name; - const memberExpertise = member?.expertise ?? comment?.member?.expertise; if (progress === 'sending' || (memberName && isAskingDetails)) { // Force open @@ -268,6 +275,69 @@ const Form: React.FC = ({comment, submit, submitText, submitSize, clo } }; + useEffect(() => { + if (!editor) { + return; + } + + // Disable editing if the member doesn't have a name or when we are submitting the form + editor.setEditable(!!memberName && progress !== 'sending'); + }, [editor, memberName, progress]); + + return ( + + + + ); +}; + +type FormWrapperProps = { + comment?: Comment; + editor: Editor | null; + isOpen: boolean; + reduced: boolean; + openForm?: OpenCommentForm; + children: React.ReactNode; +}; + +const FormWrapper: React.FC = ({ + comment, + editor, + isOpen, + reduced, + openForm, + children +}) => { + const {member, dispatchAction} = useAppContext(); + const labs = useLabs(); + + const memberName = member?.name ?? comment?.member?.name; + const memberExpertise = member?.expertise ?? comment?.member?.expertise; + + let openStyles = ''; + if (isOpen) { + const isReplyToReply = labs.commentImprovements && !!openForm?.in_reply_to_snippet; + openStyles = isReplyToReply ? 'pl-[1px] pt-[68px] sm:pl-[44px] sm:pt-[56px]' : 'pl-[1px] pt-[48px] sm:pl-[44px] sm:pt-[40px]'; + } + const openEditDetails = useCallback((options) => { editor?.commands?.blur(); @@ -275,21 +345,19 @@ const Form: React.FC = ({comment, submit, submitText, submitSize, clo type: 'addDetailsPopup', expertiseAutofocus: options.expertiseAutofocus ?? false, callback: function (succeeded: boolean) { - if (!editor || !formEl.current) { + if (!editor) { return; } - // Don't use focusEditor to avoid loop if (!succeeded) { return; } - // useEffect is not fast enought to enable it editor.setEditable(true); editor.commands.focus(); } }); - }, [editor, dispatchAction, formEl]); + }, [editor, dispatchAction]); const editName = useCallback(() => { openEditDetails({expertiseAutofocus: false}); @@ -317,43 +385,17 @@ const Form: React.FC = ({comment, submit, submitText, submitSize, clo editor.commands.focus(); }, [editor, editName, memberName]); - useEffect(() => { - if (!editor) { - return; - } - - // Disable editing if the member doesn't have a name or when we are submitting the form - editor.setEditable(!!memberName && progress !== 'sending'); - }, [editor, memberName, progress]); - return ( -
-
+
+
- +
+ {children} +
- +
= ({comment, submit, submitText, submitSize, clo
- +
); }; +export {Form, FormWrapper}; export default Form; diff --git a/apps/comments-ui/src/components/content/forms/MainForm.tsx b/apps/comments-ui/src/components/content/forms/MainForm.tsx index 928cc651197..95a130da359 100644 --- a/apps/comments-ui/src/components/content/forms/MainForm.tsx +++ b/apps/comments-ui/src/components/content/forms/MainForm.tsx @@ -1,5 +1,5 @@ -import Form from './Form'; import React, {useCallback, useEffect, useRef} from 'react'; +import {Form, FormWrapper} from './Form'; import {getEditorConfig} from '../../../utils/editor'; import {scrollToElement} from '../../../utils/helpers'; import {useAppContext} from '../../../AppContext'; @@ -8,6 +8,7 @@ import {useEditor} from '@tiptap/react'; type Props = { commentsCount: number }; + const MainForm: React.FC = ({commentsCount}) => { const {postId, dispatchAction, t} = useAppContext(); @@ -97,7 +98,14 @@ const MainForm: React.FC = ({commentsCount}) => { return (
-
+ + +
); }; diff --git a/apps/comments-ui/src/components/content/forms/ReplyForm.tsx b/apps/comments-ui/src/components/content/forms/ReplyForm.tsx index 0d52e9e08d5..a11e6156cba 100644 --- a/apps/comments-ui/src/components/content/forms/ReplyForm.tsx +++ b/apps/comments-ui/src/components/content/forms/ReplyForm.tsx @@ -1,5 +1,5 @@ -import Form from './Form'; import {Comment, OpenCommentForm, useAppContext} from '../../../AppContext'; +import {Form, FormWrapper} from './Form'; import {getEditorConfig} from '../../../utils/editor'; import {isMobile, scrollToElement} from '../../../utils/helpers'; import {useCallback} from 'react'; @@ -10,6 +10,7 @@ type Props = { openForm: OpenCommentForm; parent: Comment; } + const ReplyForm: React.FC = ({openForm, parent}) => { const {postId, dispatchAction, t} = useAppContext(); const [, setForm] = useRefCallback(scrollToElement); @@ -45,18 +46,20 @@ const ReplyForm: React.FC = ({openForm, parent}) => { ); return ( -
+
- + + +
); diff --git a/apps/comments-ui/test/e2e/actions.test.ts b/apps/comments-ui/test/e2e/actions.test.ts index 1556797d4b4..fb155175388 100644 --- a/apps/comments-ui/test/e2e/actions.test.ts +++ b/apps/comments-ui/test/e2e/actions.test.ts @@ -1,5 +1,5 @@ import {MockedApi, initialize, waitEditorFocused} from '../utils/e2e'; -import {buildReply} from '../utils/fixtures'; +import {buildMember, buildReply} from '../utils/fixtures'; import {expect, test} from '@playwright/test'; test.describe('Actions', async () => { @@ -126,8 +126,8 @@ test.describe('Actions', async () => { await waitEditorFocused(editor); // Ensure form data is correct - const form = frame.getByTestId('form'); - await expect(form.getByTestId('avatar-image')).toHaveAttribute('src', 'https://example.com/avatar.jpg'); + const replyForm = frame.getByTestId('reply-form'); + await expect(replyForm.getByTestId('avatar-image')).toHaveAttribute('src', 'https://example.com/avatar.jpg'); // Should not include the replying-to-reply indicator await expect(frame.getByTestId('replying-to')).not.toBeVisible(); @@ -454,4 +454,50 @@ test.describe('Actions', async () => { await expect(comments.nth(0)).toContainText('This is the oldest'); }); }); + + test('Can edit their own comment', async ({page}) => { + const loggedInMember = buildMember(); + mockedApi.setMember(loggedInMember); + + // Add a comment with replies + mockedApi.addComment({ + html: '

Parent comment

', + member: loggedInMember, + replies: [ + mockedApi.buildReply({ + html: '

First reply

' + }), + mockedApi.buildReply({ + html: '

Second reply

' + }) + ] + }); + + const {frame} = await initializeTest(page); + + // Get the parent comment and verify initial state + const parentComment = frame.getByTestId('comment-component').nth(0); + const replies = await parentComment.getByTestId('comment-component').all(); + + // Verify initial state shows parent and replies + await expect(parentComment).toContainText('Parent comment'); + await expect(replies[0]).toBeVisible(); + await expect(replies[0]).toContainText('First reply'); + await expect(replies[1]).toBeVisible(); + await expect(replies[1]).toContainText('Second reply'); + + // Open edit mode for parent comment + const moreButton = parentComment.getByTestId('more-button').first(); + await moreButton.click(); + await frame.getByTestId('edit').click(); + + // Verify the edit form is visible + await expect(frame.getByTestId('form-editor')).toBeVisible(); + + // Verify replies are still visible while editing + await expect(replies[0]).toBeVisible(); + await expect(replies[0]).toContainText('First reply'); + await expect(replies[1]).toBeVisible(); + await expect(replies[1]).toContainText('Second reply'); + }); }); diff --git a/apps/comments-ui/test/e2e/options.test.ts b/apps/comments-ui/test/e2e/options.test.ts index 6fe74e09fd4..256f417fa96 100644 --- a/apps/comments-ui/test/e2e/options.test.ts +++ b/apps/comments-ui/test/e2e/options.test.ts @@ -193,7 +193,15 @@ test.describe('Options', async () => { test('Uses 100 avatarSaturation', async ({page}) => { const mockedApi = new MockedApi({}); - mockedApi.addComment(); + mockedApi.addComment({ + member: { + id: 'test-id', + uuid: 'test-uuid', + name: 'Test User', + avatar: '', + expertise: '' + } + }); const {frame} = await initialize({ mockedApi,