Refactored comments-ui form management (#21621)

ref https://linear.app/ghost/issue/PLG-230
closes https://linear.app/ghost/issue/PLG-256

Adding an in-reply-to reference link/snippet to reply forms was proving difficult with the previous setup due the amount of data that needed to be passed up and down a deeply nested component tree. This refactor lays the groundwork for making that easier and aims to make form autoclose behaviour more centralised by keeping the open form state in app context and the opening/closing of forms in actions so there's less need for messy local state and to drill functions down the component tree.

- replaces `openFormCount` context state with an `openCommentForms` array
  - keeping detailed open form references in the application state means the display of forms is centrally managed rather than managed via local state inside components
  - it simplifies some of the problems faced with the `<PublishedComment>` component that previously managed form display. That component is re-used for both the top-level comment as well as replies even though replying on a reply puts the top-level comment into reply mode meaning we had a mess of local state and passed-through functions to work around the component having varying behaviour depending on nesting level
  - `openFormCount` is still available on the application state via `useMemo` on the provider meaning the implementation of `openCommentForms` is hidden from app code that just needs to know if forms are open
- removes `<AutocloseForm>` as the autoclose behaviour is now controlled via the `openCommentForm` action
- updated `<Form>` so it manages the "has unsaved changes" properties on `openFormComments` ready for use by `openCommentForm`'s autoclosing behaviour
This commit is contained in:
Kevin Ansfield 2024-11-14 18:26:23 +00:00 committed by GitHub
parent 86641268ab
commit 0ac587e94a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 283 additions and 201 deletions

View File

@ -26,6 +26,7 @@ export default defineConfig({
use: {
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
screenshot: 'only-on-failure',
launchOptions: {
slowMo: parseInt(process.env.PLAYWRIGHT_SLOWMO ?? '') || 0,
// force GPU hardware acceleration

View File

@ -25,9 +25,9 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
comments: [],
pagination: null,
commentCount: 0,
secundaryFormCount: 0,
openCommentForms: [],
popup: null,
labs: null,
labs: {},
order: 'count__likes desc, created_at desc'
});
@ -87,7 +87,8 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
...options,
...state,
t: i18n.t,
dispatchAction: dispatchAction as DispatchActionType
dispatchAction: dispatchAction as DispatchActionType,
openFormCount: useMemo(() => state.openCommentForms.length, [state.openCommentForms])
};
const initAdminAuth = async () => {

View File

@ -27,6 +27,13 @@ export type Comment = {
html: string
}
export type OpenCommentForm = {
id: string,
parent_id?: string,
type: 'reply' | 'edit',
hasUnsavedChanges: boolean
}
export type AddComment = {
post_id: string,
status: string,
@ -65,7 +72,7 @@ export type EditableAppContext = {
total: number
} | null,
commentCount: number,
secundaryFormCount: number,
openCommentForms: OpenCommentForm[],
popup: Page | null,
labs: LabsContextType,
order: string
@ -77,7 +84,8 @@ export type AppContextType = EditableAppContext & CommentsOptions & {
// This part makes sure we can add automatic data and return types to the actions when using context.dispatchAction('actionName', data)
// eslint-disable-next-line @typescript-eslint/ban-types
t: TranslationFunction,
dispatchAction: <T extends ActionType | SyncActionType>(action: T, data: Parameters<(typeof Actions & typeof SyncActions)[T]>[0] extends { data: any } ? Parameters<(typeof Actions & typeof SyncActions)[T]>[0]['data'] : any) => T extends ActionType ? Promise<void> : void
dispatchAction: <T extends ActionType | SyncActionType>(action: T, data: Parameters<(typeof Actions & typeof SyncActions)[T]>[0] extends { data: any } ? Parameters<(typeof Actions & typeof SyncActions)[T]>[0]['data'] : any) => T extends ActionType ? Promise<void> : void,
openFormCount: number
}
// Copy time from AppContextType

View File

@ -1,4 +1,4 @@
import {AddComment, Comment, CommentsOptions, EditableAppContext} from './AppContext';
import {AddComment, Comment, CommentsOptions, EditableAppContext, OpenCommentForm} from './AppContext';
import {AdminApi} from './utils/adminApi';
import {GhostApi} from './utils/api';
import {Page} from './pages';
@ -347,24 +347,56 @@ function closePopup() {
};
}
function increaseSecundaryFormCount({state}: {state: EditableAppContext}) {
return {
secundaryFormCount: state.secundaryFormCount + 1
async function openCommentForm({data: newForm, api, state}: {data: OpenCommentForm, api: GhostApi, state: EditableAppContext}) {
let otherStateChanges = {};
// When opening a reply form, we load in all of the replies for the parent comment so that
// the reply shown after posting appears in the correct place based on ordering
const topLevelCommentId = newForm.parent_id || newForm.id;
if (newForm.type === 'reply' && !state.openCommentForms.some(f => f.id === topLevelCommentId || f.parent_id === topLevelCommentId)) {
const comment = state.comments.find(c => c.id === topLevelCommentId);
const newCommentsState = await loadMoreReplies({state, api, data: {comment, limit: 'all'}});
otherStateChanges = {...otherStateChanges, ...newCommentsState};
}
// We want to keep the number of displayed forms to a minimum so when opening a
// new form, we close any existing forms that are empty or have had no changes
const openFormsAfterAutoclose = state.openCommentForms.filter(form => form.hasUnsavedChanges);
// avoid multiple forms being open for the same id
// (e.g. if "Reply" is hit on two different replies, we don't want two forms open at the bottom of that comment thread)
const openFormIndexForId = openFormsAfterAutoclose.findIndex(form => form.id === newForm.id);
if (openFormIndexForId > -1) {
openFormsAfterAutoclose[openFormIndexForId] = newForm;
return {openCommentForms: openFormsAfterAutoclose, ...otherStateChanges};
} else {
return {openCommentForms: [...openFormsAfterAutoclose, newForm], ...otherStateChanges};
};
}
function decreaseSecundaryFormCount({state}: {state: EditableAppContext}) {
return {
secundaryFormCount: state.secundaryFormCount - 1
function setCommentFormHasUnsavedChanges({data: {id, hasUnsavedChanges}, state}: {data: {id: string, hasUnsavedChanges: boolean}, state: EditableAppContext}) {
const updatedForms = state.openCommentForms.map((f) => {
if (f.id === id) {
return {...f, hasUnsavedChanges};
} else {
return {...f};
};
});
return {openCommentForms: updatedForms};
}
function closeCommentForm({data: id, state}: {data: string, state: EditableAppContext}) {
return {openCommentForms: state.openCommentForms.filter(f => f.id !== id)};
};
// Sync actions make use of setState((currentState) => newState), to avoid 'race' conditions
export const SyncActions = {
openPopup,
closePopup,
increaseSecundaryFormCount,
decreaseSecundaryFormCount
closeCommentForm,
setCommentFormHasUnsavedChanges
};
export type SyncActionType = keyof typeof SyncActions;
@ -383,7 +415,8 @@ export const Actions = {
loadMoreComments,
loadMoreReplies,
updateMember,
setOrder
setOrder,
openCommentForm
};
export type ActionType = keyof typeof Actions;

View File

@ -5,19 +5,18 @@ import Replies, {RepliesProps} from './Replies';
import ReplyButton from './buttons/ReplyButton';
import ReplyForm from './forms/ReplyForm';
import {Avatar, BlankAvatar} from './Avatar';
import {Comment, useAppContext, useLabs} from '../../AppContext';
import {Comment, OpenCommentForm, useAppContext, useLabs} from '../../AppContext';
import {Transition} from '@headlessui/react';
import {formatExplicitTime, getMemberNameFromComment, isCommentPublished} from '../../utils/helpers';
import {useCallback} from 'react';
import {useRelativeTime} from '../../utils/hooks';
import {useState} from 'react';
type AnimatedCommentProps = {
comment: Comment;
parent?: Comment;
toggleParentReplyMode?: () => Promise<void>;
};
const AnimatedComment: React.FC<AnimatedCommentProps> = ({comment, parent, toggleParentReplyMode}) => {
const AnimatedComment: React.FC<AnimatedCommentProps> = ({comment, parent}) => {
return (
<Transition
data-testid="animated-comment"
@ -30,75 +29,76 @@ const AnimatedComment: React.FC<AnimatedCommentProps> = ({comment, parent, toggl
show={true}
appear
>
<EditableComment comment={comment} parent={parent} toggleParentReplyMode={toggleParentReplyMode} />
<EditableComment comment={comment} parent={parent} />
</Transition>
);
};
type EditableCommentProps = AnimatedCommentProps;
const EditableComment: React.FC<EditableCommentProps> = ({comment, parent, toggleParentReplyMode}) => {
const [isInEditMode, setIsInEditMode] = useState(false);
const EditableComment: React.FC<EditableCommentProps> = ({comment, parent}) => {
const {openCommentForms} = useAppContext();
const closeEditMode = () => {
setIsInEditMode(false);
};
const openEditMode = () => {
setIsInEditMode(true);
};
const form = openCommentForms.find(openForm => openForm.id === comment.id && openForm.type === 'edit');
const isInEditMode = !!form;
if (isInEditMode) {
return (
<EditForm close={closeEditMode} comment={comment} parent={parent} />
);
return (<EditForm comment={comment} openForm={form} parent={parent} />);
} else {
return (<CommentComponent comment={comment} openEditMode={openEditMode} parent={parent} toggleParentReplyMode={toggleParentReplyMode} />);
return (<CommentComponent comment={comment} parent={parent} />);
}
};
type CommentProps = AnimatedCommentProps & {
openEditMode: () => void;
};
const CommentComponent: React.FC<CommentProps> = ({comment, parent, openEditMode, toggleParentReplyMode}) => {
type CommentProps = AnimatedCommentProps;
const CommentComponent: React.FC<CommentProps> = ({comment, parent}) => {
const {dispatchAction} = useAppContext();
const isPublished = isCommentPublished(comment);
const openEditMode = useCallback(() => {
const newForm: OpenCommentForm = {id: comment.id, type: 'edit', hasUnsavedChanges: false};
dispatchAction('openCommentForm', newForm);
}, [comment.id, dispatchAction]);
if (isPublished) {
return (<PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} toggleParentReplyMode={toggleParentReplyMode} />);
return (<PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} />);
}
return (<UnpublishedComment comment={comment} openEditMode={openEditMode} />);
};
const PublishedComment: React.FC<CommentProps> = ({comment, parent, openEditMode, toggleParentReplyMode}) => {
const [isInReplyMode, setIsInReplyMode] = useState(false);
const {dispatchAction} = useAppContext();
const toggleReplyMode = async () => {
if (parent && toggleParentReplyMode) {
return await toggleParentReplyMode();
type PublishedCommentProps = CommentProps & {
openEditMode: () => void;
}
const PublishedComment: React.FC<PublishedCommentProps> = ({comment, parent, openEditMode}) => {
const {dispatchAction, openCommentForms} = useAppContext();
if (!isInReplyMode) {
// First load all the replies before opening the reply model
await dispatchAction('loadMoreReplies', {comment, limit: 'all'});
// 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');
// avoid displaying the reply form inside RepliesContainer
const displayReplyForm = openForm && (!openForm.parent_id || openForm.parent_id === comment.id);
// only highlight the reply button for the comment that is being replied to
const highlightReplyButton = !!(openForm && openForm.id === comment.id);
const openReplyForm = useCallback(async () => {
if (openForm && openForm.id === comment.id) {
dispatchAction('closeCommentForm', openForm.id);
} else {
const newForm: OpenCommentForm = {id: comment.id, parent_id: parent?.id, type: 'reply', hasUnsavedChanges: false};
await dispatchAction('openCommentForm', newForm);
}
setIsInReplyMode(current => !current);
};
}, [comment, parent, openForm, dispatchAction]);
const closeReplyMode = () => {
setIsInReplyMode(false);
};
const hasReplies = isInReplyMode || (comment.replies && comment.replies.length > 0);
const hasReplies = displayReplyForm || (comment.replies && comment.replies.length > 0);
const avatar = (<Avatar comment={comment} />);
return (
<CommentLayout avatar={avatar} hasReplies={hasReplies}>
<CommentHeader comment={comment} />
<CommentBody html={comment.html} />
<CommentMenu comment={comment} isInReplyMode={isInReplyMode} openEditMode={openEditMode} parent={parent} toggleReplyMode={toggleReplyMode} />
<CommentMenu comment={comment} highlightReplyButton={highlightReplyButton} openEditMode={openEditMode} openReplyForm={openReplyForm} parent={parent} />
<RepliesContainer comment={comment} toggleReplyMode={toggleReplyMode} />
<ReplyFormBox closeReplyMode={closeReplyMode} comment={comment} isInReplyMode={isInReplyMode} />
<RepliesContainer comment={comment} />
{displayReplyForm && <ReplyFormBox comment={comment} openForm={openForm} />}
</CommentLayout>
);
};
@ -164,7 +164,7 @@ const EditedInfo: React.FC<{comment: Comment}> = ({comment}) => {
);
};
const RepliesContainer: React.FC<RepliesProps> = ({comment, toggleReplyMode}) => {
const RepliesContainer: React.FC<RepliesProps> = ({comment}) => {
const hasReplies = comment.replies && comment.replies.length > 0;
if (!hasReplies) {
@ -173,24 +173,19 @@ const RepliesContainer: React.FC<RepliesProps> = ({comment, toggleReplyMode}) =>
return (
<div className="mb-4 ml-[-1.4rem] mt-7 sm:mb-0 sm:mt-8">
<Replies comment={comment} toggleReplyMode={toggleReplyMode} />
<Replies comment={comment} />
</div>
);
};
type ReplyFormBoxProps = {
comment: Comment;
isInReplyMode: boolean;
closeReplyMode: () => void;
openForm: OpenCommentForm;
};
const ReplyFormBox: React.FC<ReplyFormBoxProps> = ({comment, isInReplyMode, closeReplyMode}) => {
if (!isInReplyMode) {
return null;
}
const ReplyFormBox: React.FC<ReplyFormBoxProps> = ({comment, openForm}) => {
return (
<div className="my-8 sm:my-10">
<ReplyForm close={closeReplyMode} parent={comment} />
<ReplyForm openForm={openForm} parent={comment} />
</div>
);
};
@ -239,12 +234,12 @@ const CommentBody: React.FC<{html: string}> = ({html}) => {
type CommentMenuProps = {
comment: Comment;
toggleReplyMode: () => void;
isInReplyMode: boolean;
openReplyForm: () => void;
highlightReplyButton: boolean;
openEditMode: () => void;
parent?: Comment;
};
const CommentMenu: React.FC<CommentMenuProps> = ({comment, toggleReplyMode, isInReplyMode, openEditMode, parent}) => {
const CommentMenu: React.FC<CommentMenuProps> = ({comment, openReplyForm, highlightReplyButton, openEditMode, parent}) => {
// 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
const {member, commentsEnabled} = useAppContext();
@ -257,7 +252,7 @@ const CommentMenu: React.FC<CommentMenuProps> = ({comment, toggleReplyMode, isIn
return (
<div className="flex items-center gap-4">
{<LikeButton comment={comment} />}
{(canReply && <ReplyButton isReplying={isInReplyMode} toggleReply={toggleReplyMode} />)}
{(canReply && <ReplyButton isReplying={highlightReplyButton} openReplyForm={openReplyForm} />)}
{<MoreButton comment={comment} toggleEdit={openEditMode} />}
</div>
);

View File

@ -6,7 +6,7 @@ const contextualRender = (ui, {appContext, ...renderOptions}) => {
const contextWithDefaults = {
commentsEnabled: 'all',
comments: [],
secundaryFormCount: 0,
openCommentForms: [],
member: null,
t: str => str,
...appContext
@ -45,7 +45,7 @@ describe('Content', function () {
});
it('renders no CTA or form when a reply form is open', function () {
contextualRender(<Content />, {appContext: {member: {}, secundaryFormCount: 1}});
contextualRender(<Content />, {appContext: {member: {}, openFormCount: 1}});
expect(screen.queryByTestId('cta-box')).not.toBeInTheDocument();
expect(screen.queryByTestId('main-form')).not.toBeInTheDocument();
});

View File

@ -10,9 +10,8 @@ import {useEffect} from 'react';
const Content = () => {
const labs = useLabs();
const {t} = useAppContext();
const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, openFormCount, t} = useAppContext();
const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, secundaryFormCount} = useAppContext();
let commentsElements;
const commentsDataset = comments;
@ -43,7 +42,7 @@ const Content = () => {
const isPaidOnly = commentsEnabled === 'paid';
const isPaidMember = member && !!member.paid;
const isFirst = pagination?.total === 0;
const hasOpenReplyForms = secundaryFormCount > 0;
const hasOpenReplyForms = openFormCount > 0;
return (
labs.commentImprovements ? (

View File

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

View File

@ -4,13 +4,13 @@ import {useAppContext} from '../../../AppContext';
type Props = {
disabled?: boolean;
isReplying: boolean;
toggleReply: () => void;
openReplyForm: () => void;
};
const ReplyButton: React.FC<Props> = ({disabled, isReplying, toggleReply}) => {
const ReplyButton: React.FC<Props> = ({disabled, isReplying, openReplyForm}) => {
const {member, t} = useAppContext();
return member ?
(<button className={`duration-50 group flex items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${isReplying ? '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="reply-button" disabled={!!disabled} type="button" onClick={toggleReply}>
(<button className={`duration-50 group flex items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${isReplying ? '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="reply-button" disabled={!!disabled} type="button" onClick={openReplyForm}>
<ReplyIcon className={`mr-[6px] ${isReplying ? 'fill-black dark:fill-white' : 'stroke-black/50 group-hover:stroke-black/75 dark:stroke-white/60 dark:group-hover:stroke-white/75'} duration-50 transition ease-linear`} />{t('Reply')}
</button>) : null;
};

View File

@ -1,16 +1,17 @@
import SecundaryForm from './SecundaryForm';
import {Comment, useAppContext} from '../../../AppContext';
import Form from './Form';
import {Comment, OpenCommentForm, useAppContext} from '../../../AppContext';
import {getEditorConfig} from '../../../utils/editor';
import {isMobile} from '../../../utils/helpers';
import {useCallback, useEffect} from 'react';
import {useEditor} from '@tiptap/react';
type Props = {
openForm: OpenCommentForm;
comment: Comment;
parent?: Comment;
close: () => void;
};
const EditForm: React.FC<Props> = ({comment, parent, close}) => {
const EditForm: React.FC<Props> = ({comment, openForm, parent}) => {
const {dispatchAction, t} = useAppContext();
const config = {
@ -54,21 +55,25 @@ const EditForm: React.FC<Props> = ({comment, parent, close}) => {
});
}, [parent, comment, dispatchAction]);
const submitProps = {
submitText: t('Save'),
submitSize: 'small',
submit
};
const closeIfNotChanged = useCallback(() => {
if (editor?.getHTML() === comment.html) {
close();
}
}, [editor, close, comment.html]);
const close = useCallback(() => {
dispatchAction('closeCommentForm', openForm.id);
}, [dispatchAction, openForm]);
return (
<div className='px-3 pb-2 pt-3'>
<SecundaryForm close={close} closeIfNotChanged={closeIfNotChanged} editor={editor} {...submitProps} />
<div className='mt-[-16px] pr-3'>
<Form
close={close}
comment={comment}
editor={editor}
isOpen={true}
openForm={openForm}
reduced={isMobile()}
submit={submit}
submitSize={'small'}
submitText={t('Save')}
/>
</div>
</div>
);
};

View File

@ -1,6 +1,6 @@
import React from 'react';
import {Avatar} from '../Avatar';
import {Comment, useAppContext, useLabs} from '../../../AppContext';
import {Comment, OpenCommentForm, useAppContext, useLabs} from '../../../AppContext';
import {ReactComponent as EditIcon} from '../../../images/icons/edit.svg';
import {Editor, EditorContent} from '@tiptap/react';
import {ReactComponent as SpinnerIcon} from '../../../images/icons/spinner.svg';
@ -11,6 +11,7 @@ import {usePopupOpen} from '../../../utils/hooks';
type Progress = 'default' | 'sending' | 'sent' | 'error';
export type SubmitSize = 'small' | 'medium' | 'large';
type FormEditorProps = {
comment?: Comment;
submit: (data: {html: string}) => Promise<void>;
progress: Progress;
setProgress: (progress: Progress) => void;
@ -20,17 +21,28 @@ type FormEditorProps = {
editor: Editor | null;
submitText: React.ReactNode;
submitSize: SubmitSize;
openForm?: OpenCommentForm;
};
const FormEditor: React.FC<FormEditorProps> = ({submit, progress, setProgress, close, reduced, isOpen, editor, submitText, submitSize}) => {
const FormEditor: React.FC<FormEditorProps> = ({comment, submit, progress, setProgress, close, reduced, isOpen, editor, submitText, submitSize, openForm}) => {
const labs = useLabs();
const {t} = useAppContext();
const {dispatchAction, t} = useAppContext();
let buttonIcon = null;
const [hasContent, setHasContent] = useState(false);
useEffect(() => {
if (editor) {
const checkContent = () => {
setHasContent(!editor.isEmpty);
const editorHasContent = !editor.isEmpty;
setHasContent(editorHasContent);
if (openForm) {
const hasUnsavedChanges = comment && openForm.type === 'edit' ? editor.getHTML() !== comment.html : editorHasContent;
// avoid unnecessary state updates to prevent infinite loops
if (openForm.hasUnsavedChanges !== hasUnsavedChanges) {
dispatchAction('setCommentFormHasUnsavedChanges', {id: openForm.id, hasUnsavedChanges});
}
}
};
editor.on('update', checkContent);
editor.on('transaction', checkContent);
@ -42,7 +54,7 @@ const FormEditor: React.FC<FormEditorProps> = ({submit, progress, setProgress, c
editor.off('transaction', checkContent);
};
}
}, [editor]);
}, [editor, comment, openForm, dispatchAction]);
if (progress === 'sending') {
submitText = null;
@ -210,6 +222,7 @@ const FormHeader: React.FC<FormHeaderProps> = ({show, name, expertise, editName,
};
type FormProps = {
openForm: OpenCommentForm;
comment?: Comment;
editor: Editor | null;
submit: (data: {html: string}) => Promise<void>;
@ -220,7 +233,7 @@ type FormProps = {
reduced: boolean;
};
const Form: React.FC<FormProps> = ({comment, submit, submitText, submitSize, close, editor, reduced, isOpen}) => {
const Form: React.FC<FormProps> = ({comment, submit, submitText, submitSize, close, editor, reduced, isOpen, openForm}) => {
const {member, dispatchAction} = useAppContext();
const isAskingDetails = usePopupOpen('addDetailsPopup');
const [progress, setProgress] = useState<Progress>('default');
@ -300,13 +313,22 @@ const Form: React.FC<FormProps> = ({comment, submit, submitText, submitSize, clo
}, [editor, memberName, progress]);
return (
<form ref={formEl} className={`-mx-3 mb-7 mt-[-10px] rounded-md transition duration-200 ${isOpen ? 'cursor-default' : 'cursor-pointer'}`} data-testid="form" onClick={focusEditor} onMouseDown={preventIfFocused} onTouchStart={preventIfFocused}>
<form
ref={formEl}
className={`-mx-3 mb-7 mt-[-10px] rounded-md transition duration-200 ${isOpen ? 'cursor-default' : 'cursor-pointer'}`}
data-testid="form"
onClick={focusEditor}
onMouseDown={preventIfFocused}
onTouchStart={preventIfFocused}
>
<div className="relative w-full">
<div className="pr-[1px] font-sans leading-normal dark:text-neutral-300">
<FormEditor
close={close}
comment={comment}
editor={editor}
isOpen={isOpen}
openForm={openForm}
progress={progress}
reduced={reduced}
setProgress={setProgress}
@ -320,7 +342,13 @@ const Form: React.FC<FormProps> = ({comment, submit, submitText, submitSize, clo
<Avatar comment={comment} />
</div>
<div className="grow-1 mt-0.5 w-full">
<FormHeader editExpertise={editExpertise} editName={editName} expertise={memberExpertise} name={memberName} show={isOpen} />
<FormHeader
editExpertise={editExpertise}
editName={editName}
expertise={memberExpertise}
name={memberName}
show={isOpen}
/>
</div>
</div>
</div>

View File

@ -1,16 +1,16 @@
import SecundaryForm from './SecundaryForm';
import {Comment, useAppContext} from '../../../AppContext';
import Form from './Form';
import {Comment, OpenCommentForm, useAppContext} from '../../../AppContext';
import {getEditorConfig} from '../../../utils/editor';
import {scrollToElement} from '../../../utils/helpers';
import {isMobile, scrollToElement} from '../../../utils/helpers';
import {useCallback} from 'react';
import {useEditor} from '@tiptap/react';
import {useRefCallback} from '../../../utils/hooks';
type Props = {
openForm: OpenCommentForm;
parent: Comment;
close: () => void;
}
const ReplyForm: React.FC<Props> = ({parent, close}) => {
const ReplyForm: React.FC<Props> = ({openForm, parent}) => {
const {postId, dispatchAction, t} = useAppContext();
const [, setForm] = useRefCallback<HTMLDivElement>(scrollToElement);
@ -35,25 +35,29 @@ const ReplyForm: React.FC<Props> = ({parent, close}) => {
});
}, [parent, postId, dispatchAction]);
const submitProps = {
submitText: (
<>
<span className="hidden sm:inline">{t('Add reply')}</span><span className="sm:hidden">{t('Reply')}</span>
</>
),
submitSize: 'medium',
submit
};
const close = useCallback(() => {
dispatchAction('closeCommentForm', openForm.id);
}, [dispatchAction, openForm]);
const closeIfNotChanged = useCallback(() => {
if (editor?.isEmpty) {
close();
}
}, [editor, close]);
const SubmitText = (<>
<span className="hidden sm:inline">{t('Add reply')}</span><span className="sm:hidden">{t('Reply')}</span>
</>);
return (
<div ref={setForm}>
<SecundaryForm close={close} closeIfNotChanged={closeIfNotChanged} editor={editor} {...submitProps} />
<div className='mt-[-16px] pr-3'>
<Form
close={close}
comment={parent}
editor={editor}
isOpen={true}
openForm={openForm}
reduced={isMobile()}
submit={submit}
submitSize={'medium'}
submitText={SubmitText}
/>
</div>
</div>
);
};

View File

@ -1,55 +0,0 @@
import Form, {SubmitSize} from './Form';
import {Editor} from '@tiptap/react';
import {isMobile} from '../../../utils/helpers';
import {useAppContext} from '../../../AppContext';
import {useEffect} from 'react';
import {useSecondUpdate} from '../../../utils/hooks';
type Props = {
editor: Editor;
submit: (data: {html: string}) => Promise<void>;
close: () => void;
closeIfNotChanged: () => void;
submitText: JSX.Element;
submitSize: SubmitSize;
};
const SecundaryForm: React.FC<Props> = ({editor, submit, close, closeIfNotChanged, submitText, submitSize}) => {
const {dispatchAction, secundaryFormCount} = useAppContext();
// Keep track of the amount of open forms
useEffect(() => {
dispatchAction('increaseSecundaryFormCount', {});
return () => {
dispatchAction('decreaseSecundaryFormCount', {});
};
}, [dispatchAction]);
useSecondUpdate(() => {
// We use useSecondUpdate because:
// first call is the mounting of the form
// second call is the increaseSecundaryFormCount from our own
// third call means a different SecondaryForm is mounted or unmounted, and we need to close if not changed
if (secundaryFormCount > 1) {
closeIfNotChanged();
};
}, [secundaryFormCount]);
const reduced = isMobile();
return (
<div className='mt-[-16px] pr-3'>
<Form
close={close}
editor={editor}
isOpen={true}
reduced={reduced}
submit={submit}
submitSize={submitSize}
submitText={submitText}
/>
</div>
);
};
export default SecundaryForm;

View File

@ -258,7 +258,7 @@ test.describe('Actions', async () => {
await expect(sortingForm).toBeVisible();
});
test('Defaut sorting is by Best', async ({page}) => {
test('Default sorting is by Best', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 1</p>',
count: {

View File

@ -0,0 +1,64 @@
import {FrameLocator, expect, test} from '@playwright/test';
import {MockedApi, initialize, waitEditorFocused} from '../utils/e2e';
test.describe('Autoclose forms', async () => {
let mockedApi: MockedApi;
let frame: FrameLocator;
test.beforeEach(async ({page}) => {
mockedApi = new MockedApi({});
mockedApi.setMember({});
mockedApi.addComment({
html: '<p>Comment 1</p>',
replies: [{
html: '<p>Reply 1.1</p>'
}, {
html: '<p>Reply 1.2</p>'
}]
});
mockedApi.addComment({
html: '<p>Comment 2</p>'
});
({frame} = await initialize({
mockedApi,
page,
publication: 'Publisher weekly',
labs: {
commentImprovements: true
}
}));
});
// NOTE: form counts are replies + 1 for the main form that is now always shown
// at the top of the comments list
test('autocloses open reply forms when opening another', async ({}) => {
const comment1 = await frame.getByTestId('comment-component').nth(0);
await comment1.getByTestId('reply-button').click();
await expect(frame.getByTestId('form')).toHaveCount(2);
const comment2 = await frame.getByTestId('comment-component').nth(3);
await comment2.getByTestId('reply-button').click();
await expect(frame.getByTestId('form')).toHaveCount(2);
});
test('does not autoclose open reply form with unsaved changes', async ({}) => {
const comment1 = await frame.getByTestId('comment-component').nth(0);
await comment1.getByTestId('reply-button').click();
await expect(frame.getByTestId('form')).toHaveCount(2);
const editor = frame.getByTestId('form-editor').nth(1);
await waitEditorFocused(editor);
await editor.type('Replying to comment 1');
const comment2 = await frame.getByTestId('comment-component').nth(3);
await comment2.getByTestId('reply-button').click();
await expect(frame.getByTestId('form')).toHaveCount(3);
});
});