mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-22 02:45:44 +03:00
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:
parent
86641268ab
commit
0ac587e94a
@ -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
|
||||
|
@ -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 () => {
|
||||
|
@ -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
|
||||
|
@ -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';
|
||||
@ -19,7 +19,7 @@ async function loadMoreComments({state, api, options, order}: {state: EditableAp
|
||||
|
||||
async function setOrder({data: {order}, options, api}: {state: EditableAppContext, data: {order: string}, options: CommentsOptions, api: GhostApi}) {
|
||||
const data = await api.comments.browse({page: 1, postId: options.postId, order: order});
|
||||
|
||||
|
||||
return {
|
||||
comments: [...data.comments],
|
||||
pagination: data.meta.pagination,
|
||||
@ -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;
|
||||
|
@ -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();
|
||||
type PublishedCommentProps = CommentProps & {
|
||||
openEditMode: () => void;
|
||||
}
|
||||
const PublishedComment: React.FC<PublishedCommentProps> = ({comment, parent, openEditMode}) => {
|
||||
const {dispatchAction, openCommentForms} = useAppContext();
|
||||
|
||||
const toggleReplyMode = async () => {
|
||||
if (parent && toggleParentReplyMode) {
|
||||
return await toggleParentReplyMode();
|
||||
// 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);
|
||||
}
|
||||
}, [comment, parent, openForm, dispatchAction]);
|
||||
|
||||
if (!isInReplyMode) {
|
||||
// First load all the replies before opening the reply model
|
||||
await dispatchAction('loadMoreReplies', {comment, limit: 'all'});
|
||||
}
|
||||
setIsInReplyMode(current => !current);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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 ? (
|
||||
@ -82,10 +81,10 @@ const Content = () => {
|
||||
</div>
|
||||
<div>
|
||||
{!hasOpenReplyForms
|
||||
? (member ? (isPaidMember || !isPaidOnly ? <MainForm commentsCount={commentCount} /> :
|
||||
? (member ? (isPaidMember || !isPaidOnly ? <MainForm commentsCount={commentCount} /> :
|
||||
<section className={`flex flex-col items-center pt-[40px] ${member ? 'pb-[32px]' : 'pb-[48px]'} ${!isFirst && 'mt-4'} ${(!member || (member && isPaidOnly)) && commentCount ? 'border-t' : 'border-none'} border-[rgba(0,0,0,0.075)] sm:px-8 dark:border-[rgba(255,255,255,0.1)]`} data-testid="cta-box">
|
||||
<CTABox isFirst={isFirst} isPaid={isPaidOnly} />
|
||||
</section>) :
|
||||
</section>) :
|
||||
<section className={`flex flex-col items-center pt-[40px] ${member ? 'pb-[32px]' : 'pb-[48px]'} ${!isFirst && 'mt-4'} ${(!member || (member && isPaidOnly)) && commentCount ? 'border-t' : 'border-none'} border-[rgba(0,0,0,0.075)] sm:px-8 dark:border-[rgba(255,255,255,0.1)]`} data-testid="cta-box">
|
||||
<CTABox isFirst={isFirst} isPaid={isPaidOnly} />
|
||||
</section>)
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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,21 +21,32 @@ 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);
|
||||
|
||||
|
||||
checkContent();
|
||||
|
||||
return () => {
|
||||
@ -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;
|
||||
@ -139,8 +151,8 @@ const FormEditor: React.FC<FormEditorProps> = ({submit, progress, setProgress, c
|
||||
{labs.commentImprovements ? (
|
||||
<button
|
||||
className={`flex w-auto items-center justify-center ${submitSize === 'medium' && 'sm:min-w-[100px]'} ${submitSize === 'small' && 'sm:min-w-[64px]'} h-[40px] rounded-md px-3 py-2 text-center font-sans text-base font-medium outline-0 transition-all duration-200 sm:text-sm ${
|
||||
hasContent
|
||||
? 'bg-[var(--gh-accent-color)] text-white hover:brightness-105'
|
||||
hasContent
|
||||
? 'bg-[var(--gh-accent-color)] text-white hover:brightness-105'
|
||||
: 'bg-black/5 text-neutral-900 dark:bg-white/15 dark:text-neutral-300'
|
||||
}`}
|
||||
data-testid="submit-form-button"
|
||||
@ -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,18 +313,27 @@ 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}
|
||||
editor={editor}
|
||||
isOpen={isOpen}
|
||||
progress={progress}
|
||||
reduced={reduced}
|
||||
setProgress={setProgress}
|
||||
submit={submit}
|
||||
submitSize={submitSize}
|
||||
<FormEditor
|
||||
close={close}
|
||||
comment={comment}
|
||||
editor={editor}
|
||||
isOpen={isOpen}
|
||||
openForm={openForm}
|
||||
progress={progress}
|
||||
reduced={reduced}
|
||||
setProgress={setProgress}
|
||||
submit={submit}
|
||||
submitSize={submitSize}
|
||||
submitText={submitText}
|
||||
/>
|
||||
</div>
|
||||
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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: {
|
||||
|
64
apps/comments-ui/test/e2e/autoclose-forms.test.ts
Normal file
64
apps/comments-ui/test/e2e/autoclose-forms.test.ts
Normal 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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user