diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-11-07 16:55:58 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:15:27 +0100 |
| commit | bd9c9ae7e2ae973969569dd434836de9f38b07d4 (patch) | |
| tree | 84905097c4f2c2db36794c20910e3893189a65e1 /src | |
| parent | c9c1c90b30e243563bb4f731da15b3fe657556d2 (diff) | |
refactor(components): split Comment component into 3 components
* add ApprovedComment, PendingComment and ReplyCommentForm components
* let consumer handle reply form visibility
* move structured data into article page (each article already has the
comments data and already handle json ltd schema so I prefered to move
the schema in the final consumer instead of adding a script element
foreach comment)
Diffstat (limited to 'src')
28 files changed, 786 insertions, 468 deletions
diff --git a/src/components/organisms/layout/comment.module.scss b/src/components/organisms/comment/approved-comment/approved-comment.module.scss index 096f4c4..7906632 100644 --- a/src/components/organisms/layout/comment.module.scss +++ b/src/components/organisms/comment/approved-comment/approved-comment.module.scss @@ -1,23 +1,10 @@ -@use "../../../styles/abstracts/functions" as fun; -@use "../../../styles/abstracts/mixins" as mix; -@use "../../../styles/abstracts/placeholders"; - -.avatar { - img { - border-radius: fun.convert-px(3); - box-shadow: - 0 0 0 fun.convert-px(1) var(--color-shadow-light), - fun.convert-px(2) fun.convert-px(2) 0 fun.convert-px(1) - var(--color-shadow); - } -} +@use "../../../../styles/abstracts/placeholders"; .author { color: var(--color-primary-darker); font-family: var(--font-family-regular); font-size: var(--font-size-md); font-weight: 600; - text-align: center; text-shadow: none; } @@ -63,16 +50,3 @@ } } } - -.form { - &__wrapper { - margin-top: var(--spacing-sm); - } - - &__heading { - width: fit-content; - margin: 0 auto var(--spacing-md) auto; - } - - margin-inline: auto; -} diff --git a/src/components/organisms/comment/approved-comment/approved-comment.stories.tsx b/src/components/organisms/comment/approved-comment/approved-comment.stories.tsx new file mode 100644 index 0000000..36afa6b --- /dev/null +++ b/src/components/organisms/comment/approved-comment/approved-comment.stories.tsx @@ -0,0 +1,126 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { ApprovedComment } from './approved-comment'; + +/** + * ApprovedComment - Storybook Meta + */ +export default { + title: 'Organisms/Comment/ApprovedComment', + component: ApprovedComment, + argTypes: { + author: { + description: 'The author data.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + content: { + control: { + type: 'text', + }, + description: 'The comment body.', + type: { + name: 'string', + required: true, + }, + }, + id: { + control: { + type: 'string', + }, + description: 'The comment id.', + type: { + name: 'string', + required: true, + }, + }, + publicationDate: { + control: { + type: 'text', + }, + description: 'The publication date.', + type: { + name: 'string', + required: true, + }, + }, + replyBtn: { + control: { + type: null, + }, + description: 'Add a reply button.', + type: { + name: 'function', + required: false, + }, + }, + }, +} as ComponentMeta<typeof ApprovedComment>; + +const Template: ComponentStory<typeof ApprovedComment> = (args) => ( + <ApprovedComment {...args} /> +); + +/** + * ApprovedComment Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { + author: { + name: 'Kameron.Conn', + }, + content: + 'Quia est eos deserunt qui perferendis est pariatur eaque. Deserunt omnis quis consectetur ea quam a cupiditate. Velit laboriosam rem nihil numquam quia.', + id: 1, + publicationDate: '2023-11-06', +}; + +/** + * ApprovedComment Stories - WithAvatar + */ +export const WithAvatar = Template.bind({}); +WithAvatar.args = { + author: { + avatar: { + alt: 'Kameron.Conn avatar', + src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/82.jpg', + }, + name: 'Kameron.Conn', + }, + content: + 'Quia est eos deserunt qui perferendis est pariatur eaque. Deserunt omnis quis consectetur ea quam a cupiditate. Velit laboriosam rem nihil numquam quia.', + id: 2, + publicationDate: '2023-11-06', +}; + +/** + * ApprovedComment Stories - WithWebsite + */ +export const WithWebsite = Template.bind({}); +WithWebsite.args = { + author: { + name: 'Kameron.Conn', + website: 'https://www.armandphilippot.com/', + }, + content: + 'Quia est eos deserunt qui perferendis est pariatur eaque. Deserunt omnis quis consectetur ea quam a cupiditate. Velit laboriosam rem nihil numquam quia.', + id: 3, + publicationDate: '2023-11-06', +}; + +/** + * ApprovedComment Stories - WithReplyBtn + */ +export const WithReplyBtn = Template.bind({}); +WithReplyBtn.args = { + author: { + name: 'Kameron.Conn', + }, + content: + 'Quia est eos deserunt qui perferendis est pariatur eaque. Deserunt omnis quis consectetur ea quam a cupiditate. Velit laboriosam rem nihil numquam quia.', + id: 4, + publicationDate: '2023-11-06', + replyBtn: 'Reply', +}; diff --git a/src/components/organisms/comment/approved-comment/approved-comment.test.tsx b/src/components/organisms/comment/approved-comment/approved-comment.test.tsx new file mode 100644 index 0000000..2e29b5f --- /dev/null +++ b/src/components/organisms/comment/approved-comment/approved-comment.test.tsx @@ -0,0 +1,108 @@ +import { describe, expect, it } from '@jest/globals'; +import { userEvent } from '@testing-library/user-event'; +import { render, screen as rtlScreen } from '../../../../../tests/utils'; +import { ApprovedComment, type CommentAuthor } from './approved-comment'; + +describe('ApprovedComment', () => { + const user = userEvent.setup(); + + it('renders the author, the publication date, the comment and a permalink', () => { + const author = { + name: 'Delbert_Jacobi45', + } satisfies CommentAuthor; + const content = 'Repellat ab non et.'; + const id = 1; + const publicationDate = '2023'; + + render( + <ApprovedComment + author={author} + content={content} + id={id} + publicationDate={publicationDate} + /> + ); + + expect(rtlScreen.getByText(author.name)).toBeInTheDocument(); + expect(rtlScreen.getByText(content)).toBeInTheDocument(); + expect( + rtlScreen.getByText(new RegExp(publicationDate)) + ).toBeInTheDocument(); + expect(rtlScreen.getByRole('link')).toHaveAttribute( + 'href', + `#comment-${id}` + ); + }); + + it('can render the author avatar', () => { + const author = { + avatar: { + alt: 'enim ut maiores', + src: 'https://picsum.photos/640/480', + }, + name: 'Sandra82', + } satisfies CommentAuthor; + + render( + <ApprovedComment + author={author} + content="Ab qui aliquam esse." + id={2} + publicationDate="2022-11-03" + /> + ); + + expect(rtlScreen.getByRole('img')).toHaveAccessibleName(author.avatar.alt); + }); + + it('can render a link to the author website', () => { + const author = { + name: 'Esmeralda51', + website: 'http://example.net', + } satisfies CommentAuthor; + + render( + <ApprovedComment + author={author} + content="Ab qui aliquam esse." + id={2} + publicationDate="2022-11-03" + /> + ); + + expect(rtlScreen.getByRole('link', { name: author.name })).toHaveAttribute( + 'href', + author.website + ); + }); + + it('can render a reply button', async () => { + const id = 6; + const replyBtn = 'dolore recusandae voluptas'; + const handleReply = jest.fn((_id: number) => { + // do nothing + }); + + render( + <ApprovedComment + author={{ name: 'Kurtis5' }} + content="Ab qui aliquam esse." + id={id} + onReply={handleReply} + publicationDate="2022-11-03" + replyBtn={replyBtn} + /> + ); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(4); + + expect(rtlScreen.getByRole('button')).toHaveTextContent(replyBtn); + expect(handleReply).not.toHaveBeenCalled(); + + await user.click(rtlScreen.getByRole('button')); + + expect(handleReply).toHaveBeenCalledTimes(1); + expect(handleReply).toHaveBeenCalledWith(id); + }); +}); diff --git a/src/components/organisms/comment/approved-comment/approved-comment.tsx b/src/components/organisms/comment/approved-comment/approved-comment.tsx new file mode 100644 index 0000000..db5345b --- /dev/null +++ b/src/components/organisms/comment/approved-comment/approved-comment.tsx @@ -0,0 +1,177 @@ +import NextImage from 'next/image'; +import { type ForwardRefRenderFunction, forwardRef, useCallback } from 'react'; +import { useIntl } from 'react-intl'; +import { Button, Link, Time } from '../../../atoms'; +import { + Card, + CardBody, + CardCover, + CardHeader, + CardMeta, + type CardProps, + CardTitle, + CardFooter, + CardActions, +} from '../../../molecules'; +import styles from './approved-comment.module.scss'; + +export type CommentAuthorAvatar = { + /** + * The alternative text for the avatar. + */ + alt: string; + /** + * The avatar url. + */ + src: string; +}; + +export type CommentAuthor = { + /** + * The author avatar. + */ + avatar?: CommentAuthorAvatar; + /** + * The author name. + */ + name: string; + /** + * The author website. + */ + website?: string; +}; + +export type CommentReplyHandler = (id: number) => void | Promise<void>; + +export type ApprovedCommentProps = Omit< + CardProps<undefined>, + | 'children' + | 'content' + | 'cover' + | 'id' + | 'isCentered' + | 'linkTo' + | 'meta' + | 'variant' +> & { + /** + * The author data. + */ + author: CommentAuthor; + /** + * The comment. + */ + content: string; + /** + * The comment id. + */ + id: number; + /** + * A callback function to handle reply. + */ + onReply?: CommentReplyHandler; + /** + * The publication date of the comment. + */ + publicationDate: string; + /** + * Add a reply button to the comment by providing a label. + */ + replyBtn?: string; +}; + +const ApprovedCommentWithRef: ForwardRefRenderFunction< + HTMLDivElement, + ApprovedCommentProps +> = ( + { + author, + className = '', + content, + id, + onReply, + publicationDate, + replyBtn, + ...props + }, + ref +) => { + const intl = useIntl(); + const commentClass = `${className}`; + const commentId = `comment-${id}`; + const commentLink = `#${commentId}`; + const publicationDateLabel = intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'ApprovedComment: publication date label', + id: 'NzeU3V', + }); + + const handleReply = useCallback(() => { + if (onReply) onReply(id); + }, [id, onReply]); + + return ( + <Card + {...props} + className={commentClass} + cover={ + author.avatar ? ( + <CardCover hasBorders> + <NextImage + alt={author.avatar.alt} + height={96} + src={author.avatar.src} + width={96} + /> + </CardCover> + ) : undefined + } + id={commentId} + ref={ref} + variant={2} + > + <CardHeader> + <CardTitle className={styles.author} isFake> + {author.website ? ( + <Link href={author.website}>{author.name}</Link> + ) : ( + author.name + )} + </CardTitle> + <CardMeta + hasInlinedItems + items={[ + { + id: 'publication-date', + label: publicationDateLabel, + value: ( + <Link href={commentLink}> + <Time date={publicationDate} showTime /> + </Link> + ), + }, + ]} + /> + </CardHeader> + <CardBody + className={styles.body} + dangerouslySetInnerHTML={{ __html: content }} + /> + {replyBtn ? ( + <CardFooter> + <CardActions> + <Button + // eslint-disable-next-line react/jsx-no-literals + kind="tertiary" + onClick={handleReply} + > + {replyBtn} + </Button> + </CardActions> + </CardFooter> + ) : null} + </Card> + ); +}; + +export const ApprovedComment = forwardRef(ApprovedCommentWithRef); diff --git a/src/components/organisms/comment/approved-comment/index.ts b/src/components/organisms/comment/approved-comment/index.ts new file mode 100644 index 0000000..444b0c3 --- /dev/null +++ b/src/components/organisms/comment/approved-comment/index.ts @@ -0,0 +1 @@ +export * from './approved-comment'; diff --git a/src/components/organisms/comment/index.ts b/src/components/organisms/comment/index.ts new file mode 100644 index 0000000..60ab263 --- /dev/null +++ b/src/components/organisms/comment/index.ts @@ -0,0 +1,3 @@ +export * from './approved-comment'; +export * from './reply-comment-form'; +export * from './pending-comment'; diff --git a/src/components/organisms/comment/pending-comment/index.ts b/src/components/organisms/comment/pending-comment/index.ts new file mode 100644 index 0000000..9fc189f --- /dev/null +++ b/src/components/organisms/comment/pending-comment/index.ts @@ -0,0 +1 @@ +export * from './pending-comment'; diff --git a/src/components/organisms/comment/pending-comment/pending-comment.stories.tsx b/src/components/organisms/comment/pending-comment/pending-comment.stories.tsx new file mode 100644 index 0000000..1b6e1d9 --- /dev/null +++ b/src/components/organisms/comment/pending-comment/pending-comment.stories.tsx @@ -0,0 +1,21 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { PendingComment } from './pending-comment'; + +/** + * PendingComment - Storybook Meta + */ +export default { + title: 'Organisms/Comment/PendingComment', + component: PendingComment, + argTypes: {}, +} as ComponentMeta<typeof PendingComment>; + +const Template: ComponentStory<typeof PendingComment> = (args) => ( + <PendingComment {...args} /> +); + +/** + * PendingComment Stories - Default + */ +export const Default = Template.bind({}); +Default.args = {}; diff --git a/src/components/organisms/comment/pending-comment/pending-comment.test.tsx b/src/components/organisms/comment/pending-comment/pending-comment.test.tsx new file mode 100644 index 0000000..87914c3 --- /dev/null +++ b/src/components/organisms/comment/pending-comment/pending-comment.test.tsx @@ -0,0 +1,13 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../../tests/utils'; +import { PendingComment } from './pending-comment'; + +describe('PendingComment', () => { + it('renders a text to inform user', () => { + render(<PendingComment />); + + expect( + rtlScreen.getByText('This comment is awaiting moderation…') + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/organisms/comment/pending-comment/pending-comment.tsx b/src/components/organisms/comment/pending-comment/pending-comment.tsx new file mode 100644 index 0000000..0d37ac2 --- /dev/null +++ b/src/components/organisms/comment/pending-comment/pending-comment.tsx @@ -0,0 +1,36 @@ +import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { useIntl } from 'react-intl'; +import { Card, CardBody, type CardProps } from '../../../molecules'; + +export type PendingCommentProps = Omit< + CardProps<undefined>, + | 'children' + | 'content' + | 'cover' + | 'id' + | 'isCentered' + | 'linkTo' + | 'meta' + | 'variant' +>; + +const PendingCommentWithRef: ForwardRefRenderFunction< + HTMLDivElement, + PendingCommentProps +> = (props, ref) => { + const intl = useIntl(); + + return ( + <Card {...props} ref={ref} variant={2}> + <CardBody> + {intl.formatMessage({ + defaultMessage: 'This comment is awaiting moderation…', + description: 'PendingComment: awaiting moderation text', + id: '1d/xvG', + })} + </CardBody> + </Card> + ); +}; + +export const PendingComment = forwardRef(PendingCommentWithRef); diff --git a/src/components/organisms/comment/reply-comment-form/index.ts b/src/components/organisms/comment/reply-comment-form/index.ts new file mode 100644 index 0000000..d461a03 --- /dev/null +++ b/src/components/organisms/comment/reply-comment-form/index.ts @@ -0,0 +1 @@ +export * from './reply-comment-form'; diff --git a/src/components/organisms/comment/reply-comment-form/reply-comment-form.module.scss b/src/components/organisms/comment/reply-comment-form/reply-comment-form.module.scss new file mode 100644 index 0000000..cd7c3ca --- /dev/null +++ b/src/components/organisms/comment/reply-comment-form/reply-comment-form.module.scss @@ -0,0 +1,13 @@ +.body { + margin-inline: auto; + width: 100%; +} + +.form { + margin-inline: auto; +} + +:where(.body) > *:first-child { + width: fit-content; + margin: 0 auto var(--spacing-md); +} diff --git a/src/components/organisms/comment/reply-comment-form/reply-comment-form.stories.tsx b/src/components/organisms/comment/reply-comment-form/reply-comment-form.stories.tsx new file mode 100644 index 0000000..57174ea --- /dev/null +++ b/src/components/organisms/comment/reply-comment-form/reply-comment-form.stories.tsx @@ -0,0 +1,25 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Heading } from '../../../atoms'; +import { ReplyCommentForm } from './reply-comment-form'; + +/** + * ReplyCommentForm - Storybook Meta + */ +export default { + title: 'Organisms/Comment/ReplyCommentForm', + component: ReplyCommentForm, + argTypes: {}, +} as ComponentMeta<typeof ReplyCommentForm>; + +const Template: ComponentStory<typeof ReplyCommentForm> = (args) => ( + <ReplyCommentForm {...args} /> +); + +/** + * ReplyCommentForm Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { + commentId: 5, + heading: <Heading level={2}>Reply to comment 5</Heading>, +}; diff --git a/src/components/organisms/comment/reply-comment-form/reply-comment-form.test.tsx b/src/components/organisms/comment/reply-comment-form/reply-comment-form.test.tsx new file mode 100644 index 0000000..f02dd48 --- /dev/null +++ b/src/components/organisms/comment/reply-comment-form/reply-comment-form.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../../tests/utils'; +import { Heading } from '../../../atoms'; +import { ReplyCommentForm } from './reply-comment-form'; + +describe('ReplyCommentForm', () => { + it('renders a form with a heading', () => { + const commentId = 5; + const heading = 'odio autem voluptas'; + const headingLvl = 3; + + render( + <ReplyCommentForm + commentId={commentId} + heading={<Heading level={headingLvl}>{heading}</Heading>} + /> + ); + + expect( + rtlScreen.getByRole('heading', { level: headingLvl }) + ).toHaveTextContent(heading); + expect(rtlScreen.getByRole('form')).toHaveAccessibleName( + `Leave a reply to comment ${commentId}` + ); + }); +}); diff --git a/src/components/organisms/comment/reply-comment-form/reply-comment-form.tsx b/src/components/organisms/comment/reply-comment-form/reply-comment-form.tsx new file mode 100644 index 0000000..a027ba2 --- /dev/null +++ b/src/components/organisms/comment/reply-comment-form/reply-comment-form.tsx @@ -0,0 +1,74 @@ +import { + type ForwardRefRenderFunction, + type ReactElement, + type ReactNode, + forwardRef, +} from 'react'; +import { useIntl } from 'react-intl'; +import type { HeadingProps } from '../../../atoms'; +import { Card, CardBody, type CardProps } from '../../../molecules'; +import { CommentForm, type CommentFormProps } from '../../forms'; +import styles from './reply-comment-form.module.scss'; + +export type ReplyCommentFormProps = Omit< + CardProps<undefined>, + | 'children' + | 'content' + | 'cover' + | 'id' + | 'isCentered' + | 'linkTo' + | 'meta' + | 'onSubmit' + | 'variant' +> & + Pick<CommentFormProps, 'onSubmit'> & { + /** + * Add additional contents below the form. + */ + children?: ReactNode; + /** + * The comment id related to the reply. + */ + commentId: number; + /** + * The form heading. + */ + heading: ReactElement<HeadingProps>; + }; + +const ReplyCommentFormWithRef: ForwardRefRenderFunction< + HTMLDivElement, + ReplyCommentFormProps +> = ( + { children, className = '', commentId, heading, onSubmit, ...props }, + ref +) => { + const wrapperClass = `${styles.wrapper} ${className}`; + const intl = useIntl(); + const formLabel = intl.formatMessage( + { + defaultMessage: 'Leave a reply to comment {id}', + description: 'ReplyCommentForm: an accessible name for the reply form', + id: 'ndAawq', + }, + { id: commentId } + ); + + return ( + <Card {...props} className={wrapperClass} ref={ref} variant={2}> + <CardBody className={styles.body}> + {heading} + <CommentForm + aria-label={formLabel} + className={styles.form} + onSubmit={onSubmit} + parentId={commentId} + /> + {children} + </CardBody> + </Card> + ); +}; + +export const ReplyCommentForm = forwardRef(ReplyCommentFormWithRef); diff --git a/src/components/organisms/index.ts b/src/components/organisms/index.ts index 43414fa..7962603 100644 --- a/src/components/organisms/index.ts +++ b/src/components/organisms/index.ts @@ -1,3 +1,4 @@ +export * from './comment'; export * from './forms'; export * from './layout'; export * from './nav'; diff --git a/src/components/organisms/layout/comment.fixture.ts b/src/components/organisms/layout/comment.fixture.ts deleted file mode 100644 index 84aa20e..0000000 --- a/src/components/organisms/layout/comment.fixture.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { UserCommentProps } from './comment'; - -export const author = { - avatar: { - alt: 'Author avatar', - height: 480, - src: 'http://placeimg.com/640/480', - width: 640, - }, - name: 'Armand', - website: 'https://www.armandphilippot.com/', -}; - -export const content = - 'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.'; - -export const date = '2021-04-03 23:04:24'; - -export const meta = { - author, - date, -}; - -export const id = 5; - -export const saveComment = () => undefined; - -export const data: UserCommentProps = { - approved: true, - content, - id, - meta, - parentId: 0, - onSubmit: saveComment, -}; diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx deleted file mode 100644 index 7426fc3..0000000 --- a/src/components/organisms/layout/comment.stories.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { UserComment } from './comment'; -import { data } from './comment.fixture'; - -const saveComment = async () => { - /** Do nothing. */ -}; - -/** - * Comment - Storybook Meta - */ -export default { - title: 'Organisms/Layout/Comment', - component: UserComment, - args: { - canReply: true, - onSubmit: saveComment, - }, - argTypes: { - author: { - description: 'The author data.', - type: { - name: 'object', - required: true, - value: {}, - }, - }, - canReply: { - control: { - type: 'boolean', - }, - description: 'Enable or disable the reply button.', - table: { - category: 'Options', - defaultValue: { summary: true }, - }, - type: { - name: 'boolean', - required: false, - }, - }, - content: { - control: { - type: 'text', - }, - description: 'The comment body.', - type: { - name: 'string', - required: true, - }, - }, - id: { - control: { - type: 'number', - }, - description: 'The comment id.', - type: { - name: 'number', - required: true, - }, - }, - parentId: { - control: { - type: null, - }, - description: 'The parent id if it is a reply.', - type: { - name: 'number', - required: false, - }, - }, - publication: { - description: 'The publication date.', - type: { - name: 'object', - required: true, - value: {}, - }, - }, - onSubmit: { - control: { - type: null, - }, - description: 'A callback function to save the comment form data.', - table: { - category: 'Events', - }, - type: { - name: 'function', - required: true, - }, - }, - }, -} as ComponentMeta<typeof UserComment>; - -const Template: ComponentStory<typeof UserComment> = (args) => ( - <UserComment {...args} /> -); - -/** - * Layout Stories - Approved - */ -export const Approved = Template.bind({}); -Approved.args = { - ...data, -}; - -/** - * Layout Stories - Unapproved - */ -export const Unapproved = Template.bind({}); -Unapproved.args = { - ...data, - approved: false, -}; diff --git a/src/components/organisms/layout/comment.test.tsx b/src/components/organisms/layout/comment.test.tsx deleted file mode 100644 index 0e0ea3a..0000000 --- a/src/components/organisms/layout/comment.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { UserComment } from './comment'; -import { author, data, id } from './comment.fixture'; - -describe('UserComment', () => { - it('renders an avatar', () => { - render(<UserComment canReply={true} {...data} />); - expect( - rtlScreen.getByRole('img', { name: author.avatar.alt }) - ).toBeInTheDocument(); - }); - - it('renders the author website url', () => { - render(<UserComment canReply={true} {...data} />); - expect(rtlScreen.getByRole('link', { name: author.name })).toHaveAttribute( - 'href', - author.website - ); - }); - - it('renders a permalink to the comment', () => { - render(<UserComment canReply={true} {...data} />); - expect( - rtlScreen.getByRole('link', { - name: /\sat\s/, - }) - ).toHaveAttribute('href', `#comment-${id}`); - }); - - it('renders a reply button', () => { - render(<UserComment canReply={true} {...data} />); - expect( - rtlScreen.getByRole('button', { name: 'Reply' }) - ).toBeInTheDocument(); - }); - - it('does not render a reply button', () => { - render(<UserComment canReply={false} {...data} />); - expect( - rtlScreen.queryByRole('button', { name: 'Reply' }) - ).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx deleted file mode 100644 index b55bb3d..0000000 --- a/src/components/organisms/layout/comment.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import NextImage from 'next/image'; -import Script from 'next/script'; -import type { FC } from 'react'; -import { useIntl } from 'react-intl'; -import type { Comment as CommentSchema, WithContext } from 'schema-dts'; -import type { SingleComment } from '../../../types'; -import { useSettings, useToggle } from '../../../utils/hooks'; -import { Button, Heading, Link, Time } from '../../atoms'; -import { - Card, - CardActions, - CardBody, - CardCover, - CardFooter, - CardHeader, - CardMeta, - CardTitle, -} from '../../molecules'; -import { CommentForm, type CommentFormProps } from '../forms'; -import styles from './comment.module.scss'; - -export type UserCommentProps = Pick< - SingleComment, - 'approved' | 'content' | 'id' | 'meta' | 'parentId' -> & - Pick<CommentFormProps, 'onSubmit'> & { - /** - * Enable or disable the reply button. Default: true. - */ - canReply?: boolean; - }; - -/** - * UserComment component - * - * Render a single comment. - */ -export const UserComment: FC<UserCommentProps> = ({ - approved, - canReply = true, - content, - id, - meta, - onSubmit, - parentId, - ...props -}) => { - const intl = useIntl(); - const { website } = useSettings(); - const [isReplying, toggleIsReplying] = useToggle(false); - - if (!approved) { - return ( - <div className={styles.wrapper}> - {intl.formatMessage({ - defaultMessage: 'This comment is awaiting moderation...', - description: 'Comment: awaiting moderation', - id: '6a1Uo6', - })} - </div> - ); - } - - const { author, date } = meta; - - const buttonLabel = isReplying - ? intl.formatMessage({ - defaultMessage: 'Cancel reply', - description: 'Comment: cancel reply button', - id: 'LCorTC', - }) - : intl.formatMessage({ - defaultMessage: 'Reply', - description: 'Comment: reply button', - id: 'hzHuCc', - }); - const formTitle = intl.formatMessage({ - defaultMessage: 'Leave a reply', - description: 'Comment: comment form title', - id: '2fD5CI', - }); - - const commentSchema: WithContext<CommentSchema> = { - '@context': 'https://schema.org', - '@id': `${website.url}/#comment-${id}`, - '@type': 'Comment', - parentItem: parentId - ? { '@id': `${website.url}/#comment-${parentId}` } - : undefined, - about: { '@type': 'Article', '@id': `${website.url}/#article` }, - author: { - '@type': 'Person', - name: author.name, - image: author.avatar?.src, - url: author.website, - }, - creator: { - '@type': 'Person', - name: author.name, - image: author.avatar?.src, - url: author.website, - }, - dateCreated: date, - datePublished: date, - text: content, - }; - - return ( - <> - <Script - dangerouslySetInnerHTML={{ __html: JSON.stringify(commentSchema) }} - id="schema-comments" - type="application/ld+json" - /> - <Card - cover={ - author.avatar ? ( - <CardCover className={styles.avatar}> - <NextImage - {...props} - alt={author.avatar.alt} - fill - src={author.avatar.src} - style={{ objectFit: 'cover' }} - /> - </CardCover> - ) : undefined - } - id={`comment-${id}`} - variant={2} - > - <CardHeader> - <CardTitle className={styles.author} isFake> - {author.website ? ( - <Link href={author.website}>{author.name}</Link> - ) : ( - author.name - )} - </CardTitle> - <CardMeta - hasInlinedItems - items={[ - { - id: 'publication-date', - label: intl.formatMessage({ - defaultMessage: 'Published on:', - description: 'Comment: publication date label', - id: 'soj7do', - }), - value: ( - <Link href={`#comment-${id}`}> - <Time date={date} showTime /> - </Link> - ), - }, - ]} - /> - </CardHeader> - <CardBody - className={styles.body} - dangerouslySetInnerHTML={{ __html: content }} - /> - {canReply ? ( - <CardFooter> - <CardActions> - <Button kind="tertiary" onClick={toggleIsReplying}> - {buttonLabel} - </Button> - </CardActions> - </CardFooter> - ) : null} - </Card> - {isReplying ? ( - <Card className={styles.form__wrapper} variant={2}> - <CardBody> - <Heading className={styles.form__heading} level={2}> - {formTitle} - </Heading> - <CommentForm - className={styles.form} - onSubmit={onSubmit} - parentId={id} - /> - </CardBody> - </Card> - ) : null} - </> - ); -}; diff --git a/src/components/organisms/layout/comments-list.fixture.ts b/src/components/organisms/layout/comments-list.fixture.ts index 30a4f11..c6a1891 100644 --- a/src/components/organisms/layout/comments-list.fixture.ts +++ b/src/components/organisms/layout/comments-list.fixture.ts @@ -104,3 +104,5 @@ export const comments: SingleComment[] = [ replies: [], }, ]; + +export const saveComment = () => undefined; diff --git a/src/components/organisms/layout/comments-list.module.scss b/src/components/organisms/layout/comments-list.module.scss new file mode 100644 index 0000000..e690250 --- /dev/null +++ b/src/components/organisms/layout/comments-list.module.scss @@ -0,0 +1,3 @@ +.reply { + margin-top: var(--spacing-sm); +} diff --git a/src/components/organisms/layout/comments-list.test.tsx b/src/components/organisms/layout/comments-list.test.tsx index d7e170c..2a05204 100644 --- a/src/components/organisms/layout/comments-list.test.tsx +++ b/src/components/organisms/layout/comments-list.test.tsx @@ -1,8 +1,7 @@ import { describe, it } from '@jest/globals'; import { render } from '../../../../tests/utils'; -import { saveComment } from './comment.fixture'; import { CommentsList } from './comments-list'; -import { comments } from './comments-list.fixture'; +import { comments, saveComment } from './comments-list.fixture'; describe('CommentsList', () => { it('renders a comments list', () => { diff --git a/src/components/organisms/layout/comments-list.tsx b/src/components/organisms/layout/comments-list.tsx index 2d43583..385aea9 100644 --- a/src/components/organisms/layout/comments-list.tsx +++ b/src/components/organisms/layout/comments-list.tsx @@ -1,12 +1,20 @@ -import type { FC } from 'react'; +import { useState, type FC, useCallback } from 'react'; +import { useIntl } from 'react-intl'; import type { SingleComment } from '../../../types'; -import { List, ListItem } from '../../atoms'; -import { UserComment, type UserCommentProps } from './comment'; +import { Heading, List, ListItem } from '../../atoms'; +import { + ApprovedComment, + type CommentReplyHandler, + PendingComment, + ReplyCommentForm, + type ReplyCommentFormProps, +} from '../comment'; +import styles from './comments-list.module.scss'; // eslint-disable-next-line @typescript-eslint/no-magic-numbers export type CommentsListDepth = 0 | 1 | 2 | 3 | 4; -export type CommentsListProps = Pick<UserCommentProps, 'onSubmit'> & { +export type CommentsListProps = Pick<ReplyCommentFormProps, 'onSubmit'> & { /** * An array of comments. */ @@ -27,6 +35,21 @@ export const CommentsList: FC<CommentsListProps> = ({ depth, onSubmit, }) => { + const [replyingTo, setReplyingTo] = useState<number | null>(null); + const intl = useIntl(); + const replyFormHeading = intl.formatMessage({ + defaultMessage: 'Leave a reply', + description: 'CommentsList: comment form title', + id: 'w8uLLF', + }); + + const handleReplyFormVisibility: CommentReplyHandler = useCallback((id) => { + setReplyingTo((prevId) => { + if (prevId === id) return null; + return id; + }); + }, []); + /** * Get each comment wrapped in a list item. * @@ -39,16 +62,53 @@ export const CommentsList: FC<CommentsListProps> = ({ ): JSX.Element[] => { const isLastLevel = startLevel === depth; - return commentsList.map(({ replies, ...comment }) => ( - <ListItem key={comment.id}> - <UserComment canReply={!isLastLevel} onSubmit={onSubmit} {...comment} /> - {replies.length && !isLastLevel ? ( - <List hideMarker isOrdered spacing="sm"> - {getItems(replies, startLevel + 1)} - </List> - ) : null} - </ListItem> - )); + return commentsList.map( + ({ approved, meta, replies, parentId, ...comment }) => { + const replyBtnLabel = + replyingTo === comment.id + ? intl.formatMessage({ + defaultMessage: 'Cancel reply', + description: 'CommentsList: cancel reply button', + id: 'uZj4QI', + }) + : intl.formatMessage({ + defaultMessage: 'Reply', + description: 'CommentsList: reply button', + id: 'Qa9twM', + }); + + return ( + <ListItem key={comment.id}> + {approved ? ( + <> + <ApprovedComment + {...comment} + author={meta.author} + onReply={handleReplyFormVisibility} + publicationDate={meta.date} + replyBtn={replyBtnLabel} + /> + {replyingTo === comment.id ? ( + <ReplyCommentForm + className={styles.reply} + heading={<Heading level={2}>{replyFormHeading}</Heading>} + onSubmit={onSubmit} + commentId={comment.id} + /> + ) : null} + </> + ) : ( + <PendingComment /> + )} + {replies.length && !isLastLevel ? ( + <List hideMarker isOrdered spacing="sm"> + {getItems(replies, startLevel + 1)} + </List> + ) : null} + </ListItem> + ); + } + ); }; return ( diff --git a/src/components/organisms/layout/index.ts b/src/components/organisms/layout/index.ts index 552ed27..55a9357 100644 --- a/src/components/organisms/layout/index.ts +++ b/src/components/organisms/layout/index.ts @@ -1,4 +1,3 @@ -export * from './comment'; export * from './comments-list'; export * from './no-results'; export * from './overview'; diff --git a/src/i18n/en.json b/src/i18n/en.json index 4b649cc..3e6bf93 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -47,6 +47,10 @@ "defaultMessage": "Show help", "description": "Tooltip: show help label" }, + "1d/xvG": { + "defaultMessage": "This comment is awaiting moderation…", + "description": "PendingComment: awaiting moderation text" + }, "1dCuCx": { "defaultMessage": "Name:", "description": "ContactForm: name label" @@ -71,10 +75,6 @@ "defaultMessage": "Written by:", "description": "PostPreviewMeta: author label" }, - "2fD5CI": { - "defaultMessage": "Leave a reply", - "description": "Comment: comment form title" - }, "2ukj9H": { "defaultMessage": "Share by Email", "description": "Sharing: Email sharing link" @@ -123,10 +123,6 @@ "defaultMessage": "Copy", "description": "usePrism: copy button text (not clicked)" }, - "6a1Uo6": { - "defaultMessage": "This comment is awaiting moderation...", - "description": "Comment: awaiting moderation" - }, "75FYp7": { "defaultMessage": "Github profile", "description": "ContactPage: Github profile link" @@ -323,10 +319,6 @@ "defaultMessage": "Popularity:", "description": "ProjectsPage: popularity label" }, - "LCorTC": { - "defaultMessage": "Cancel reply", - "description": "Comment: cancel reply button" - }, "LszkU6": { "defaultMessage": "All posts in {thematicName}", "description": "ThematicPage: posts list heading" @@ -359,6 +351,10 @@ "defaultMessage": "Github profile", "description": "ProjectsPage: Github profile link" }, + "NzeU3V": { + "defaultMessage": "Published on:", + "description": "ApprovedComment: publication date label" + }, "OL0Yzx": { "defaultMessage": "Publish", "description": "CommentForm: submit button" @@ -387,6 +383,10 @@ "defaultMessage": "Dark Theme 🌙", "description": "usePrism: toggle dark theme button text" }, + "Qa9twM": { + "defaultMessage": "Reply", + "description": "CommentsList: reply button" + }, "Qh2CwH": { "defaultMessage": "Find me elsewhere", "description": "ContactPage: social media widget title" @@ -551,10 +551,6 @@ "defaultMessage": "Light Theme 🌞", "description": "usePrism: toggle light theme button text" }, - "hzHuCc": { - "defaultMessage": "Reply", - "description": "Comment: reply button" - }, "i5L19t": { "defaultMessage": "Shaarli", "description": "HomePage: link to Shaarli" @@ -619,6 +615,10 @@ "defaultMessage": "Ackee tracking (analytics)", "description": "AckeeToggle: tooltip title" }, + "ndAawq": { + "defaultMessage": "Leave a reply to comment {id}", + "description": "ReplyCommentForm: an accessible name for the reply form" + }, "npisb3": { "defaultMessage": "Search for a post on {websiteName}.", "description": "SearchPage: SEO - Meta description" @@ -695,10 +695,6 @@ "defaultMessage": "Contact me", "description": "HomePage: contact button text" }, - "soj7do": { - "defaultMessage": "Published on:", - "description": "Comment: publication date label" - }, "suXOBu": { "defaultMessage": "Theme:", "description": "ThemeToggle: theme label" @@ -727,6 +723,10 @@ "defaultMessage": "{postsCount, plural, =0 {No articles} one {# article} other {# articles}}", "description": "TopicPage: posts count meta" }, + "uZj4QI": { + "defaultMessage": "Cancel reply", + "description": "CommentsList: cancel reply button" + }, "uaqd5F": { "defaultMessage": "Load more articles?", "description": "PostsList: load more button" @@ -751,6 +751,10 @@ "defaultMessage": "Free", "description": "HomePage: link to free thematic" }, + "w8uLLF": { + "defaultMessage": "Leave a reply", + "description": "CommentsList: comment form title" + }, "wQrvgw": { "defaultMessage": "Updated on:", "description": "ProjectsPage: update date label" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 25991d4..9244863 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -47,6 +47,10 @@ "defaultMessage": "Afficher l’aide", "description": "Tooltip: show help label" }, + "1d/xvG": { + "defaultMessage": "Ce commentaire est en attente de modération…", + "description": "PendingComment: awaiting moderation text" + }, "1dCuCx": { "defaultMessage": "Nom :", "description": "ContactForm: name label" @@ -71,10 +75,6 @@ "defaultMessage": "Écrit par :", "description": "PostPreviewMeta: author label" }, - "2fD5CI": { - "defaultMessage": "Laisser une réponse", - "description": "Comment: comment form title" - }, "2ukj9H": { "defaultMessage": "Partager par email", "description": "Sharing: Email sharing link" @@ -123,10 +123,6 @@ "defaultMessage": "Copier", "description": "usePrism: copy button text (not clicked)" }, - "6a1Uo6": { - "defaultMessage": "Ce commentaire est en attente de modération…", - "description": "Comment: awaiting moderation" - }, "75FYp7": { "defaultMessage": "Profil Github", "description": "ContactPage: Github profile link" @@ -323,10 +319,6 @@ "defaultMessage": "Popularité :", "description": "ProjectsPage: popularity label" }, - "LCorTC": { - "defaultMessage": "Annuler la réponse", - "description": "Comment: cancel reply button" - }, "LszkU6": { "defaultMessage": "Tous les articles dans {thematicName}", "description": "ThematicPage: posts list heading" @@ -359,6 +351,10 @@ "defaultMessage": "Profil Github", "description": "ProjectsPage: Github profile link" }, + "NzeU3V": { + "defaultMessage": "Publié le :", + "description": "ApprovedComment: publication date label" + }, "OL0Yzx": { "defaultMessage": "Publier", "description": "CommentForm: submit button" @@ -387,6 +383,10 @@ "defaultMessage": "Thème sombre 🌙", "description": "usePrism: toggle dark theme button text" }, + "Qa9twM": { + "defaultMessage": "Répondre", + "description": "CommentsList: reply button" + }, "Qh2CwH": { "defaultMessage": "Retrouvez-moi ailleurs", "description": "ContactPage: social media widget title" @@ -551,10 +551,6 @@ "defaultMessage": "Thème clair 🌞", "description": "usePrism: toggle light theme button text" }, - "hzHuCc": { - "defaultMessage": "Répondre", - "description": "Comment: reply button" - }, "i5L19t": { "defaultMessage": "Shaarli", "description": "HomePage: link to Shaarli" @@ -619,6 +615,10 @@ "defaultMessage": "Suivi Ackee (analytique)", "description": "AckeeToggle: tooltip title" }, + "ndAawq": { + "defaultMessage": "Répondre au commentaire {id}", + "description": "ReplyCommentForm: an accessible name for the reply form" + }, "npisb3": { "defaultMessage": "Rechercher un article sur {websiteName}.", "description": "SearchPage: SEO - Meta description" @@ -695,10 +695,6 @@ "defaultMessage": "Me contacter", "description": "HomePage: contact button text" }, - "soj7do": { - "defaultMessage": "Publié le :", - "description": "Comment: publication date label" - }, "suXOBu": { "defaultMessage": "Thème :", "description": "ThemeToggle: theme label" @@ -727,6 +723,10 @@ "defaultMessage": "{postsCount, plural, =0 {0 article} one {# article} other {# articles}}", "description": "TopicPage: posts count meta" }, + "uZj4QI": { + "defaultMessage": "Annuler la réponse", + "description": "CommentsList: cancel reply button" + }, "uaqd5F": { "defaultMessage": "Charger plus d’articles ?", "description": "PostsList: load more button" @@ -751,6 +751,10 @@ "defaultMessage": "Libre", "description": "HomePage: link to free thematic" }, + "w8uLLF": { + "defaultMessage": "Laisser une réponse", + "description": "CommentsList: comment form title" + }, "wQrvgw": { "defaultMessage": "Mis à jour le :", "description": "ProjectsPage: update date label" diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx index d1e680c..449af8d 100644 --- a/src/pages/article/[slug].tsx +++ b/src/pages/article/[slug].tsx @@ -7,6 +7,7 @@ import { useRouter } from 'next/router'; import Script from 'next/script'; import type { HTMLAttributes } from 'react'; import { useIntl } from 'react-intl'; +import type { Comment as CommentSchema, WithContext } from 'schema-dts'; import { ButtonLink, getLayout, @@ -217,10 +218,39 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ slug, title, }); + const commentsSchema: WithContext<CommentSchema>[] = commentsData + ? commentsData.map((comment) => { + return { + '@context': 'https://schema.org', + '@id': `${website.url}/#comment-${comment.id}`, + '@type': 'Comment', + parentItem: comment.parentId + ? { '@id': `${website.url}/#comment-${comment.parentId}` } + : undefined, + about: { '@type': 'Article', '@id': `${website.url}/#article` }, + author: { + '@type': 'Person', + name: comment.meta.author.name, + image: comment.meta.author.avatar?.src, + url: comment.meta.author.website, + }, + creator: { + '@type': 'Person', + name: comment.meta.author.name, + image: comment.meta.author.avatar?.src, + url: comment.meta.author.website, + }, + dateCreated: comment.meta.date, + datePublished: comment.meta.date, + text: comment.content, + }; + }) + : []; const schemaJsonLd = getSchemaJson([ webpageSchema, blogSchema, blogPostSchema, + ...commentsSchema, ]); const lineNumbersClassName = className @@ -272,7 +302,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ </Head> <Script // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-project" + id="schema-article" type="application/ld+json" // eslint-disable-next-line react/no-danger -- Necessary for schema dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} |
