From 70b4f633a6fbedb58c8b9134ac64ede854d489de Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 20 Nov 2023 12:27:46 +0100 Subject: refactor(components): replace PageLayout template with Page * split pages in smaller components (it is both easier to maintain and more readable, we avoid the use of fragments in pages directory) * extract breadcrumbs from article tag (the navigation is not related to the page contents) * remove useReadingTime hook * remove layout options except `isHome` --- src/components/templates/page/index.ts | 7 +- src/components/templates/page/page-body.test.tsx | 14 + src/components/templates/page/page-body.tsx | 23 + .../templates/page/page-comments.stories.tsx | 170 +++++++ .../templates/page/page-comments.test.tsx | 103 ++++ src/components/templates/page/page-comments.tsx | 178 +++++++ .../templates/page/page-footer.stories.tsx | 41 ++ src/components/templates/page/page-footer.test.tsx | 53 +++ src/components/templates/page/page-footer.tsx | 54 +++ .../templates/page/page-header.stories.tsx | 76 +++ src/components/templates/page/page-header.test.tsx | 149 ++++++ src/components/templates/page/page-header.tsx | 172 +++++++ .../templates/page/page-layout.module.scss | 95 ---- .../templates/page/page-layout.stories.tsx | 521 --------------------- src/components/templates/page/page-layout.test.tsx | 113 ----- src/components/templates/page/page-layout.tsx | 287 ------------ .../templates/page/page-sidebar.test.tsx | 14 + src/components/templates/page/page-sidebar.tsx | 20 + src/components/templates/page/page.module.scss | 212 +++++++++ src/components/templates/page/page.stories.tsx | 456 ++++++++++++++++++ src/components/templates/page/page.test.tsx | 49 ++ src/components/templates/page/page.tsx | 56 +++ 22 files changed, 1846 insertions(+), 1017 deletions(-) create mode 100644 src/components/templates/page/page-body.test.tsx create mode 100644 src/components/templates/page/page-body.tsx create mode 100644 src/components/templates/page/page-comments.stories.tsx create mode 100644 src/components/templates/page/page-comments.test.tsx create mode 100644 src/components/templates/page/page-comments.tsx create mode 100644 src/components/templates/page/page-footer.stories.tsx create mode 100644 src/components/templates/page/page-footer.test.tsx create mode 100644 src/components/templates/page/page-footer.tsx create mode 100644 src/components/templates/page/page-header.stories.tsx create mode 100644 src/components/templates/page/page-header.test.tsx create mode 100644 src/components/templates/page/page-header.tsx delete mode 100644 src/components/templates/page/page-layout.module.scss delete mode 100644 src/components/templates/page/page-layout.stories.tsx delete mode 100644 src/components/templates/page/page-layout.test.tsx delete mode 100644 src/components/templates/page/page-layout.tsx create mode 100644 src/components/templates/page/page-sidebar.test.tsx create mode 100644 src/components/templates/page/page-sidebar.tsx create mode 100644 src/components/templates/page/page.module.scss create mode 100644 src/components/templates/page/page.stories.tsx create mode 100644 src/components/templates/page/page.test.tsx create mode 100644 src/components/templates/page/page.tsx (limited to 'src/components/templates/page') diff --git a/src/components/templates/page/index.ts b/src/components/templates/page/index.ts index cdd5a64..3b26326 100644 --- a/src/components/templates/page/index.ts +++ b/src/components/templates/page/index.ts @@ -1 +1,6 @@ -export * from './page-layout'; +export * from './page'; +export * from './page-body'; +export * from './page-comments'; +export * from './page-footer'; +export * from './page-header'; +export * from './page-sidebar'; diff --git a/src/components/templates/page/page-body.test.tsx b/src/components/templates/page/page-body.test.tsx new file mode 100644 index 0000000..28a47d7 --- /dev/null +++ b/src/components/templates/page/page-body.test.tsx @@ -0,0 +1,14 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { PageBody } from './page-body'; + +describe('PageBody', () => { + it('renders its contents', () => { + const body = + 'Consectetur deleniti laboriosam vel velit optio voluptate qui. Possimus voluptatem eos enim labore debitis iure eveniet aspernatur quibusdam. Accusamus dolore quos explicabo recusandae in illo ipsam incidunt.'; + + render({body}); + + expect(rtlScreen.getByText(body)).toBeInTheDocument(); + }); +}); diff --git a/src/components/templates/page/page-body.tsx b/src/components/templates/page/page-body.tsx new file mode 100644 index 0000000..df69731 --- /dev/null +++ b/src/components/templates/page/page-body.tsx @@ -0,0 +1,23 @@ +import { + type ForwardRefRenderFunction, + type HTMLAttributes, + forwardRef, +} from 'react'; +import styles from './page.module.scss'; + +export type PageBodyProps = HTMLAttributes; + +const PageBodyWithRef: ForwardRefRenderFunction< + HTMLDivElement, + PageBodyProps +> = ({ children, className = '', ...props }, ref) => { + const bodyClass = `${styles.body} ${className}`; + + return ( +
+ {children} +
+ ); +}; + +export const PageBody = forwardRef(PageBodyWithRef); diff --git a/src/components/templates/page/page-comments.stories.tsx b/src/components/templates/page/page-comments.stories.tsx new file mode 100644 index 0000000..362f0a4 --- /dev/null +++ b/src/components/templates/page/page-comments.stories.tsx @@ -0,0 +1,170 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { CommentData } from '../../organisms/comments-list'; +import { Page } from './page'; +import { PageComments } from './page-comments'; + +/** + * PageComments - Storybook Meta + */ +export default { + title: 'Templates/Page/Comments Section', + component: PageComments, + argTypes: { + comments: { + control: { + type: null, + }, + description: 'An array of comments.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + pageId: { + control: { + type: 'number', + }, + description: 'Define the page id in the database.', + type: { + name: 'number', + required: true, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + + + +); + +const comments = [ + { + author: { + name: 'Milan0', + avatar: { + alt: 'Milan0 avatar', + src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/976.jpg', + }, + }, + content: 'Fugit veniam quas qui dolor explicabo.', + id: 1, + isApproved: true, + publicationDate: '2023-01-23', + replies: [ + { + author: { name: 'Haskell42' }, + content: 'Error quas accusamus nesciunt enim quae a.', + id: 25, + isApproved: true, + publicationDate: '2023-02-04', + }, + { + author: { name: 'Hanna49', website: 'https://www.armandphilippot.com' }, + content: 'Ut ducimus neque aliquam soluta sed totam commodi cum sit.', + id: 30, + isApproved: true, + publicationDate: '2023-03-10', + }, + ], + }, + { + author: { + name: 'Corrine9', + avatar: { + alt: 'Corrine9 avatar', + src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/539.jpg', + }, + }, + content: + 'Dolore hic iure voluptatum quam error minima. Quas ut aperiam sit commodi cumque consequatur. Voluptas debitis veritatis officiis in voluptas ea et laborum animi. Voluptatem qui enim neque. Et sunt quo neque assumenda iure. Non vel ut consectetur.', + id: 2, + isApproved: true, + publicationDate: '2023-04-20', + }, + { + author: { name: 'Presley12' }, + content: + 'Nulla eaque similique recusandae enim aut eligendi iure consequatur. Et aut qui. Voluptatem a voluptatem consequatur aliquid distinctio ex culpa. Adipisci animi amet reprehenderit autem quia commodi voluptatum commodi.', + id: 3, + isApproved: true, + publicationDate: '2023-05-01', + replies: [ + { + author: { + name: 'Ana_Haley33', + avatar: { + alt: 'Ana_Haley33 avatar', + src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/881.jpg', + }, + }, + content: 'Ab ea et fugit autem.', + id: 17, + isApproved: true, + publicationDate: '2023-05-01', + }, + { + author: { name: 'Santos.Harris17' }, + content: + 'Illo dolores voluptatem similique voluptas quasi hic aspernatur ab nisi.', + id: 18, + isApproved: false, + publicationDate: '2023-05-02', + }, + ], + }, + { + author: { name: 'Julius.Borer' }, + content: 'Ea fugit totam et voluptatum quidem laborum explicabo fuga quod.', + id: 4, + isApproved: true, + publicationDate: '2023-06-15', + }, + { + author: { name: 'Geo87' }, + content: + 'Enim consequatur deleniti aliquid adipisci. Et mollitia saepe vel rerum totam praesentium assumenda repellat fuga. Ipsum ut architecto consequatur. Ut laborum suscipit sed corporis quas aliquid. Et et omnis quo. Dolore quia ipsum ut corporis eum et corporis qui.', + id: 5, + isApproved: false, + publicationDate: '2023-06-16', + }, + { + author: { name: 'Kurt.Keeling' }, + content: 'Eligendi repellat officiis amet.', + id: 6, + isApproved: true, + publicationDate: '2023-06-17', + }, +] satisfies CommentData[]; + +/** + * PageComments Stories - Without comments + */ +export const WithoutComments = Template.bind({}); +WithoutComments.args = { + comments: [], + pageId: 1, +}; + +/** + * PageComments Stories - With comments + */ +export const WithComments = Template.bind({}); +WithComments.args = { + comments, + depth: 2, + pageId: 1, +}; + +/** + * PageComments Stories - With comments closed + */ +export const WithCommentsClosed = Template.bind({}); +WithCommentsClosed.args = { + areCommentsClosed: true, + comments, + depth: 2, + pageId: 1, +}; diff --git a/src/components/templates/page/page-comments.test.tsx b/src/components/templates/page/page-comments.test.tsx new file mode 100644 index 0000000..32e61d7 --- /dev/null +++ b/src/components/templates/page/page-comments.test.tsx @@ -0,0 +1,103 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import type { CommentData } from '../../organisms/comments-list'; +import { PageComments } from './page-comments'; + +const comments = [ + { + author: { + name: 'Milan0', + avatar: { + alt: 'Milan0 avatar', + src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/976.jpg', + }, + }, + content: 'Fugit veniam quas qui dolor explicabo.', + id: 1, + isApproved: true, + publicationDate: '2023-01-23', + replies: [], + }, + { + author: { + name: 'Corrine9', + avatar: { + alt: 'Corrine9 avatar', + src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/539.jpg', + }, + }, + content: + 'Dolore hic iure voluptatum quam error minima. Quas ut aperiam sit commodi cumque consequatur. Voluptas debitis veritatis officiis in voluptas ea et laborum animi. Voluptatem qui enim neque. Et sunt quo neque assumenda iure. Non vel ut consectetur.', + id: 2, + isApproved: true, + publicationDate: '2023-04-20', + }, + { + author: { name: 'Presley12' }, + content: + 'Nulla eaque similique recusandae enim aut eligendi iure consequatur. Et aut qui. Voluptatem a voluptatem consequatur aliquid distinctio ex culpa. Adipisci animi amet reprehenderit autem quia commodi voluptatum commodi.', + id: 3, + isApproved: true, + publicationDate: '2023-05-01', + replies: [], + }, + { + author: { name: 'Julius.Borer' }, + content: 'Ea fugit totam et voluptatum quidem laborum explicabo fuga quod.', + id: 4, + isApproved: true, + publicationDate: '2023-06-15', + }, + { + author: { name: 'Geo87' }, + content: + 'Enim consequatur deleniti aliquid adipisci. Et mollitia saepe vel rerum totam praesentium assumenda repellat fuga. Ipsum ut architecto consequatur. Ut laborum suscipit sed corporis quas aliquid. Et et omnis quo. Dolore quia ipsum ut corporis eum et corporis qui.', + id: 5, + isApproved: false, + publicationDate: '2023-06-16', + }, + { + author: { name: 'Kurt.Keeling' }, + content: 'Eligendi repellat officiis amet.', + id: 6, + isApproved: true, + publicationDate: '2023-06-17', + }, +] satisfies CommentData[]; + +describe('PageComments', () => { + it('renders a list of comments with a form', () => { + render(); + + const headings = rtlScreen.getAllByRole('heading', { level: 2 }); + + expect(headings).toHaveLength(2); + expect(headings[0]).toHaveTextContent(`${comments.length} comments`); + expect(headings[1]).toHaveTextContent('Leave a comment'); + expect(rtlScreen.getAllByRole('listitem')).toHaveLength(comments.length); + expect(rtlScreen.getByRole('form')).toHaveAccessibleName('Comment form'); + }); + + it('can disable the comment form when comments are closed', () => { + render(); + + expect(rtlScreen.getAllByRole('heading', { level: 2 })).toHaveLength(1); + expect(rtlScreen.queryByRole('form')).not.toBeInTheDocument(); + }); + + it('can render a link to the comment form when there are no comments', () => { + render(); + + expect( + rtlScreen.getAllByRole('heading', { level: 2 })[0] + ).toHaveTextContent('No comments'); + expect(rtlScreen.queryByRole('listitem')).not.toBeInTheDocument(); + + const formSection = rtlScreen.getByRole('form').parentElement; + + expect(formSection?.id).not.toBeUndefined(); + expect( + rtlScreen.getByRole('link', { name: 'Be the first!' }) + ).toHaveAttribute('href', `#${formSection?.id}`); + }); +}); diff --git a/src/components/templates/page/page-comments.tsx b/src/components/templates/page/page-comments.tsx new file mode 100644 index 0000000..bc715e8 --- /dev/null +++ b/src/components/templates/page/page-comments.tsx @@ -0,0 +1,178 @@ +import { + type ForwardRefRenderFunction, + type HTMLAttributes, + forwardRef, + type ReactNode, + useCallback, +} from 'react'; +import { useIntl } from 'react-intl'; +import { sendComment } from '../../../services/graphql'; +import type { SendCommentInput } from '../../../types'; +import { Heading, Link } from '../../atoms'; +import { Card, CardBody } from '../../molecules'; +import { + type CommentData, + CommentsList, + type CommentsListProps, +} from '../../organisms/comments-list'; +import { CommentForm, type CommentFormSubmit } from '../../organisms/forms'; +import styles from './page.module.scss'; + +const link = (chunks: ReactNode) => ( + // eslint-disable-next-line react/jsx-no-literals + {chunks} +); + +export type PageCommentsProps = Omit< + HTMLAttributes, + 'children' | 'onSubmit' +> & + Pick & { + /** + * Should the comments form be removed from the page? + * + * @default false + */ + areCommentsClosed?: boolean; + /** + * The page comments. + */ + comments: CommentData[]; + /** + * The database page id. + */ + pageId: number; + }; + +const PageCommentsWithRef: ForwardRefRenderFunction< + HTMLDivElement, + PageCommentsProps +> = ( + { + areCommentsClosed = false, + className = '', + comments, + depth, + pageId, + ...props + }, + ref +) => { + const wrapperClass = `${styles.comments} ${className}`; + const commentsCount = + comments.length + + comments.reduce( + (accumulator, currentValue) => + accumulator + (currentValue.replies?.length ?? 0), + 0 + ); + const intl = useIntl(); + const commentsListTitle = intl.formatMessage( + { + defaultMessage: + '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}', + description: 'PageComments: the section title of the comments list', + id: 'H4pKJP', + }, + { commentsCount } + ); + const commentFormSectionTitle = intl.formatMessage({ + defaultMessage: 'Leave a comment', + description: 'PageComments: the section title of the comment form', + id: 'Y7XdNp', + }); + const commentFormTitle = intl.formatMessage({ + defaultMessage: 'Comment form', + description: 'PageComments: an accessible name for the comment form', + id: 'o+wCJz', + }); + const noCommentsYet = intl.formatMessage( + { + defaultMessage: 'No comments yet. Be the first!', + id: 'w+BpPg', + description: 'PageComments: no comments text', + }, + { + link, + } + ); + + const saveComment: CommentFormSubmit = useCallback( + async (data) => { + const commentData: SendCommentInput = { + author: data.author, + authorEmail: data.email, + authorUrl: data.website ?? '', + clientMutationId: 'comment', + commentOn: pageId, + content: data.comment, + parent: data.parentId, + }; + const { comment, success } = await sendComment(commentData); + const successPrefix = intl.formatMessage({ + defaultMessage: 'Thanks, your comment was successfully sent.', + description: 'PageComments: comment form success message', + id: 'ZcFroC', + }); + const successMessage = comment?.approved + ? intl.formatMessage({ + defaultMessage: 'It has been approved.', + id: 'UgJwSU', + description: 'PageComments: comment approved.', + }) + : intl.formatMessage({ + defaultMessage: 'It is now awaiting moderation.', + id: '/EfcyW', + description: 'PageComments: comment awaiting moderation', + }); + + return { + messages: { + success: `${successPrefix} ${successMessage}`, + }, + validator: () => success, + }; + }, + [intl, pageId] + ); + + return ( +
+
+ + {commentsListTitle} + + {comments.length ? ( + + ) : ( + + {noCommentsYet} + + )} +
+ {areCommentsClosed ? null : ( +
+ + {commentFormSectionTitle} + + +
+ )} +
+ ); +}; + +export const PageComments = forwardRef(PageCommentsWithRef); diff --git a/src/components/templates/page/page-footer.stories.tsx b/src/components/templates/page/page-footer.stories.tsx new file mode 100644 index 0000000..aee8979 --- /dev/null +++ b/src/components/templates/page/page-footer.stories.tsx @@ -0,0 +1,41 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Page } from './page'; +import { PageFooter } from './page-footer'; + +/** + * PageFooter - Storybook Meta + */ +export default { + title: 'Templates/Page/Footer', + component: PageFooter, + argTypes: { + readMoreAbout: { + control: { + type: null, + }, + description: 'An array of page links.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + + + +); + +/** + * PageFooter Stories - Footer + */ +export const Footer = Template.bind({}); +Footer.args = { + readMoreAbout: [ + { id: 1, name: 'Topic 1', url: '#topic1' }, + { id: 2, name: 'Topic 2', url: '#topic2' }, + ], +}; diff --git a/src/components/templates/page/page-footer.test.tsx b/src/components/templates/page/page-footer.test.tsx new file mode 100644 index 0000000..4af0e82 --- /dev/null +++ b/src/components/templates/page/page-footer.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import type { PageLink } from '../../../types'; +import { PageFooter } from './page-footer'; + +describe('PageFooter', () => { + it('renders a list of links', () => { + const links = [ + { id: 1, name: 'Topic 1', url: '/topic1' }, + { id: 2, name: 'Topic 2', url: '/topic2' }, + { id: 3, name: 'Topic 3', url: '/topic3' }, + ] satisfies PageLink[]; + + render(); + + expect(rtlScreen.getByRole('term')).toHaveTextContent( + 'Read more posts about:' + ); + expect(rtlScreen.getAllByRole('link')).toHaveLength(links.length); + }); + + it('can renders a list of links with logo', () => { + const links = [ + { + id: 1, + logo: { + alt: 'a logo', + height: 480, + width: 640, + src: 'https://picsum.photos/640/480', + }, + name: 'Topic 1', + url: '/topic1', + }, + { id: 2, name: 'Topic 2', url: '/topic2' }, + { id: 3, name: 'Topic 3', url: '/topic3' }, + ] satisfies PageLink[]; + + render(); + + expect(rtlScreen.getByRole('term')).toHaveTextContent( + 'Read more posts about:' + ); + expect(rtlScreen.getAllByRole('link')).toHaveLength(links.length); + expect(rtlScreen.getByRole('img')).toHaveAccessibleName(links[0].logo?.alt); + }); + + it('does not render a list when the prop length is 0', () => { + const { container } = render(); + + expect(container.firstChild).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/templates/page/page-footer.tsx b/src/components/templates/page/page-footer.tsx new file mode 100644 index 0000000..3bfece4 --- /dev/null +++ b/src/components/templates/page/page-footer.tsx @@ -0,0 +1,54 @@ +import NextImage from 'next/image'; +import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { useIntl } from 'react-intl'; +import type { PageLink } from '../../../types'; +import { Footer, type FooterProps, ButtonLink } from '../../atoms'; +import { MetaList, MetaItem } from '../../molecules'; +import styles from './page.module.scss'; + +export type PageFooterProps = Omit & { + readMoreAbout: PageLink[]; +}; + +const PageFooterWithRef: ForwardRefRenderFunction< + HTMLElement, + PageFooterProps +> = ({ className = '', readMoreAbout, ...props }, ref) => { + const footerClass = `${styles.footer} ${className}`; + const intl = useIntl(); + const metaLabel = intl.formatMessage({ + defaultMessage: 'Read more posts about:', + description: 'PageFooter: the topics list label', + id: 'I6vhfk', + }); + + return ( +
+ {readMoreAbout.length ? ( + + { + return { + id: `${item.id}`, + value: ( + + <> + {item.logo ? ( + + ) : null} + {item.name} + + + ), + }; + })} + /> + + ) : null} +
+ ); +}; + +export const PageFooter = forwardRef(PageFooterWithRef); diff --git a/src/components/templates/page/page-header.stories.tsx b/src/components/templates/page/page-header.stories.tsx new file mode 100644 index 0000000..3af9b47 --- /dev/null +++ b/src/components/templates/page/page-header.stories.tsx @@ -0,0 +1,76 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Page } from './page'; +import { PageHeader } from './page-header'; + +/** + * PageHeader - Storybook Meta + */ +export default { + title: 'Templates/Page/Header', + component: PageHeader, + argTypes: { + meta: { + control: { + type: null, + }, + description: 'Define the page meta.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + + + +); + +/** + * PageHeader Stories - TitleOnly + */ +export const TitleOnly = Template.bind({}); +TitleOnly.args = { + heading: 'The page title', +}; + +/** + * PageHeader Stories - TitleAndIntro + */ +export const TitleAndIntro = Template.bind({}); +TitleAndIntro.args = { + heading: 'The page title', + intro: + 'Eos similique impedit dolor illo. Rerum voluptates corporis quod et molestiae eum. Ut tenetur repellat hic eum. Doloremque et illum sequi aspernatur.', +}; + +/** + * PageHeader Stories - TitleAndMeta + */ +export const TitleAndMeta = Template.bind({}); +TitleAndMeta.args = { + heading: 'The page title', + meta: { + author: 'Robin_Schroeder77', + publicationDate: '2023-11-15', + updateDate: '2023-11-16', + }, +}; + +/** + * PageHeader Stories - All + */ +export const All = Template.bind({}); +All.args = { + heading: 'The page title', + intro: + 'Eos similique impedit dolor illo. Rerum voluptates corporis quod et molestiae eum. Ut tenetur repellat hic eum. Doloremque et illum sequi aspernatur.', + meta: { + author: 'Robin_Schroeder77', + publicationDate: '2023-11-15', + updateDate: '2023-11-16', + }, +}; diff --git a/src/components/templates/page/page-header.test.tsx b/src/components/templates/page/page-header.test.tsx new file mode 100644 index 0000000..9878553 --- /dev/null +++ b/src/components/templates/page/page-header.test.tsx @@ -0,0 +1,149 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import { PageHeader, type PageHeaderMetaData } from './page-header'; + +describe('PageHeader', () => { + it('renders the page title', () => { + const title = 'nostrum et impedit'; + + render(); + + expect(rtlScreen.getByRole('heading', { level: 1 })).toHaveTextContent( + title + ); + }); + + it('can render an introduction', () => { + const title = 'nostrum et impedit'; + const intro = + 'Non reiciendis error eveniet deserunt vel quis debitis incidunt voluptas. Distinctio dolorem reiciendis molestias et velit. Aut distinctio autem dolore ratione neque laudantium sed. Asperiores quo qui omnis maiores.'; + + render(); + + expect(rtlScreen.getByText(intro)).toBeInTheDocument(); + }); + + it('can render a meta for the author', () => { + const title = 'nostrum et impedit'; + const meta = { + author: 'Edward_Hansen72', + } satisfies Partial; + + render(); + + expect(rtlScreen.getAllByRole('term')).toHaveLength( + Object.keys(meta).length + ); + expect(rtlScreen.getByRole('term')).toHaveTextContent('Written by:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent(meta.author); + }); + + it('can render a meta for the publication date', () => { + const title = 'nostrum et impedit'; + const meta = { + publicationDate: '2023-11-19', + } satisfies Partial; + + render(); + + expect(rtlScreen.getAllByRole('term')).toHaveLength( + Object.keys(meta).length + ); + expect(rtlScreen.getByRole('term')).toHaveTextContent('Published on:'); + }); + + it('can render a meta for the thematics', () => { + const title = 'nostrum et impedit'; + const meta = { + thematics: [ + { id: 1, name: 'Thematic 1', url: '#thematic1' }, + { id: 2, name: 'Thematic 2', url: '#thematic2' }, + ], + } satisfies Partial; + + render(); + + expect(rtlScreen.getAllByRole('term')).toHaveLength( + Object.keys(meta).length + ); + expect(rtlScreen.getByRole('term')).toHaveTextContent('Thematics:'); + expect(rtlScreen.getAllByRole('definition')).toHaveLength( + meta.thematics.length + ); + }); + + it('can render a meta for the posts total', () => { + const title = 'nostrum et impedit'; + const meta = { + total: 40, + } satisfies Partial; + + render(); + + expect(rtlScreen.getAllByRole('term')).toHaveLength( + Object.keys(meta).length + ); + expect(rtlScreen.getByRole('term')).toHaveTextContent('Total:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + new RegExp(`${meta.total}`) + ); + }); + + it('can render a meta for the update date', () => { + const title = 'nostrum et impedit'; + const meta = { + updateDate: '2023-11-20', + } satisfies Partial; + + render(); + + expect(rtlScreen.getAllByRole('term')).toHaveLength( + Object.keys(meta).length + ); + expect(rtlScreen.getByRole('term')).toHaveTextContent('Updated on:'); + }); + + it('can render a meta for the website', () => { + const title = 'nostrum et impedit'; + const meta = { + website: 'https://example.test', + } satisfies Partial; + + render(); + + expect(rtlScreen.getAllByRole('term')).toHaveLength( + Object.keys(meta).length + ); + expect(rtlScreen.getByRole('term')).toHaveTextContent('Website:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent(meta.website); + }); + + it('can render a meta for the reading time', () => { + const title = 'nostrum et impedit'; + const meta = { + wordsCount: 640, + } satisfies Partial; + + render(); + + expect(rtlScreen.getAllByRole('term')).toHaveLength( + Object.keys(meta).length + ); + expect(rtlScreen.getByRole('term')).toHaveTextContent('Reading time:'); + }); + + it('does not render an undefined meta', () => { + const title = 'nostrum et impedit'; + const meta = { + author: undefined, + publicationDate: '2023-11-20', + } satisfies Partial; + + render(); + + expect(rtlScreen.getAllByRole('term')).toHaveLength( + // Author is invalid + Object.keys(meta).length - 1 + ); + }); +}); diff --git a/src/components/templates/page/page-header.tsx b/src/components/templates/page/page-header.tsx new file mode 100644 index 0000000..6effc9e --- /dev/null +++ b/src/components/templates/page/page-header.tsx @@ -0,0 +1,172 @@ +import { + type ForwardRefRenderFunction, + forwardRef, + type ReactNode, +} from 'react'; +import { useIntl } from 'react-intl'; +import type { PageLink } from '../../../types'; +import { getReadingTimeFrom } from '../../../utils/helpers'; +import { Header, Heading, type HeaderProps, Link, Time } from '../../atoms'; +import { MetaList, MetaItem } from '../../molecules'; +import styles from './page.module.scss'; + +export type PageHeaderMetaData = { + author: string; + publicationDate: string; + thematics: PageLink[]; + total: number; + updateDate: string; + website: string; + wordsCount: number; +}; + +export type PageHeaderProps = Omit & { + /** + * The page main title. + */ + heading: ReactNode; + /** + * The page introduction. + */ + intro?: ReactNode; + /** + * The page meta. + */ + meta?: Partial; +}; + +const PageHeaderWithRef: ForwardRefRenderFunction< + HTMLElement, + PageHeaderProps +> = ({ className = '', heading, intro, meta, ...props }, ref) => { + const headerClass = `${styles.header} ${className}`; + const intl = useIntl(); + + return ( +
+
+ + {heading} + + {meta ? ( + + {meta.author ? ( + + ) : null} + {meta.publicationDate ? ( + } + /> + ) : null} + {meta.updateDate && meta.updateDate !== meta.publicationDate ? ( + } + /> + ) : null} + {meta.wordsCount ? ( + + ) : null} + {meta.thematics ? ( + { + return { + id: `thematic-${thematic.id}`, + value: {thematic.name}, + }; + })} + /> + ) : null} + {meta.total ? ( + + ) : null} + {meta.website ? ( + + ) : null} + + ) : null} + {typeof intro === 'string' ? ( + // eslint-disable-next-line react/no-danger -- Intro can contain tags. +
+ ) : ( + intro + )} +
+
+ ); +}; + +export const PageHeader = forwardRef(PageHeaderWithRef); diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss deleted file mode 100644 index 75b996c..0000000 --- a/src/components/templates/page/page-layout.module.scss +++ /dev/null @@ -1,95 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; -@use "../../../styles/abstracts/mixins" as mix; -@use "../../../styles/abstracts/placeholders"; - -.breadcrumb { - @extend %grid; - - grid-column: 1 / -1; - width: 100%; - padding: var(--spacing-md) 0; - - > * { - grid-column: 2; - font-size: var(--font-size-sm); - } -} - -.header { - grid-column: 1 / -1; - margin-bottom: var(--spacing-md); -} - -.body { - grid-column: 2; - - > * + * { - margin-top: var(--spacing-sm); - margin-bottom: var(--spacing-sm); - } -} - -.sidebar { - grid-column: 2; - - &--first { - margin-bottom: var(--spacing-xs); - - @include mix.media("screen") { - @include mix.dimensions("lg") { - grid-column: 1; - align-self: stretch; - margin: 0 var(--spacing-xs) var(--spacing-md); - } - } - } - - &--last { - margin: var(--spacing-lg) 0 0; - - @include mix.media("screen") { - @include mix.dimensions("md") { - grid-column: 3; - align-self: stretch; - margin: 0 var(--spacing-xs) var(--spacing-md); - } - } - } -} - -.footer { - grid-column: 2; - margin: var(--spacing-sm) 0 var(--spacing-2xs); -} - -.comments { - @extend %grid; - - grid-column: 1 / -1; - margin: var(--spacing-lg) 0 0; - padding: 0 0 var(--spacing-lg); - background: var(--color-bg-secondary); - border-top: fun.convert-px(3) solid var(--color-border-light); - - &__section { - grid-column: 2; - } - - &__title { - width: fit-content; - margin: var(--spacing-md) auto; - } - - &__no-comments { - text-align: center; - } - - &__form { - max-width: 40ch; - margin: auto; - } -} - -.notice { - margin-top: var(--spacing-sm); -} diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx deleted file mode 100644 index 6dcbeea..0000000 --- a/src/components/templates/page/page-layout.stories.tsx +++ /dev/null @@ -1,521 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { ButtonLink, Heading, Link } from '../../atoms'; -import { MetaItem, MetaList } from '../../molecules'; -import { LinksWidget, PostsList, SharingWidget } from '../../organisms'; -import { LayoutBase } from '../layout/layout.stories'; -import { PageLayout as PageLayoutComponent } from './page-layout'; - -/** - * PageLayout - Storybook Meta - */ -export default { - title: 'Templates/Page', - component: PageLayoutComponent, - args: { - allowComments: false, - breadcrumbSchema: [], - }, - argTypes: { - allowComments: { - control: { - type: 'boolean', - }, - description: 'Determine if the comment form is displayed.', - table: { - category: 'Options', - defaultValue: { summary: false }, - }, - type: { - name: 'boolean', - required: false, - }, - }, - bodyAttributes: { - description: 'Set additional HTML attributes to the main content body.', - table: { - category: 'Options', - }, - type: { - name: 'object', - required: false, - value: {}, - }, - }, - bodyClassName: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the main content body.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - breadcrumb: { - description: 'The breadcrumb items.', - type: { - name: 'object', - required: true, - value: {}, - }, - }, - breadcrumbSchema: { - control: { - type: null, - }, - description: 'The JSON schema for breadcrumb items.', - type: { - name: 'object', - required: true, - value: {}, - }, - }, - children: { - control: { - type: 'text', - }, - description: 'The page content.', - type: { - name: 'string', - required: true, - }, - }, - comments: { - description: 'The page comments.', - table: { - category: 'Options', - }, - type: { - name: 'object', - required: false, - value: {}, - }, - }, - footerMeta: { - description: 'The metadata to display in the page footer.', - table: { - category: 'Options', - }, - type: { - name: 'object', - required: false, - value: {}, - }, - }, - headerMeta: { - description: 'The metadata to display in the page header.', - table: { - category: 'Options', - }, - type: { - name: 'object', - required: false, - value: {}, - }, - }, - id: { - control: { - type: 'number', - }, - description: 'The page id.', - type: { - name: 'number', - required: false, - }, - }, - intro: { - control: { - type: 'text', - }, - description: 'The page introduction.', - table: { - category: 'Options', - }, - type: { - name: 'string', - required: false, - }, - }, - title: { - control: { - type: 'text', - }, - description: 'The page title.', - type: { - name: 'string', - required: true, - }, - }, - widgets: { - control: { - type: null, - }, - description: 'An array of widgets to put inside the last sidebar.', - type: { - name: 'object', - required: false, - value: {}, - }, - }, - withToC: { - control: { - type: 'boolean', - }, - description: 'Determine if the Table of Contents should be in the page.', - table: { - category: 'Options', - defaultValue: { summary: false }, - }, - type: { - name: 'boolean', - required: false, - }, - }, - }, - decorators: [ - (Story, context) => ( - - - - ), - ], - parameters: { - layout: 'fullscreen', - }, -} as ComponentMeta; - -const Template: ComponentStory = (args) => ( - -); - -const pageTitle = 'Incidunt ad earum'; -const pageIntro = - 'Recusandae mollitia enim quo omnis rerum enim corporis ratione quidem. Pariatur omnis quas est ut ut numquam totam. Sunt sapiente nostrum aut sunt provident perspiciatis magni illum. Quidem nihil velit quasi fugit minima sint.'; -const pageBreadcrumb = [ - { id: 'home', url: '#', name: 'Home' }, - { id: 'page', url: '#', name: pageTitle }, -]; - -/** - * Page Layout Stories - Single Page - */ -export const SinglePage = Template.bind({}); -SinglePage.args = { - breadcrumb: pageBreadcrumb, - title: pageTitle, - intro: pageIntro, - children: ( - <> - Impedit commodi rerum -

- Omnis vel earum cupiditate delectus reprehenderit perferendis distinctio - omnis. Laudantium rem tempore eligendi porro officia est dolorum - assumenda. Corrupti tempore quia ab. Quidem est inventore. Autem - nesciunt sed rerum praesentium. -

-

- Illo nostrum inventore tenetur quo repellendus autem nisi nostrum - dolore. Et velit assumenda. Veniam harum officia et. Blanditiis et et - qui cum. Rerum illum quo doloribus neque non velit. Unde iusto et eaque - a ut. -

- Et omnis ducimus -

- Dolor quidem quas perferendis in nam molestiae. Accusamus quidem - accusantium quaerat est praesentium accusamus ab dolorem. Beatae illum - totam et corrupti assumenda corporis aut illo animi. -

-

- Ad rem soluta. Est tenetur consequatur sequi voluptates autem. Molestiae - in neque dignissimos. Dolorum numquam quos quam voluptas atque facilis - et. Accusantium fuga architecto excepturi consequatur libero est. -

- - ), - widgets: [ - Share} - media={[ - 'diaspora', - 'email', - 'facebook', - 'journal-du-hacker', - 'linkedin', - 'twitter', - ]} - />, - ], - withToC: true, -}; - -const postBreadcrumb = [ - { id: 'home', url: '#', name: 'Home' }, - { id: 'blog', url: '#', name: 'Blog' }, - { id: 'post', url: '#', name: pageTitle }, -]; - -/** - * Page Layout Stories - Post - */ -export const Post = Template.bind({}); -Post.args = { - breadcrumb: postBreadcrumb, - title: pageTitle, - intro: pageIntro, - headerMeta: ( - - - - Cat 1 - - ), - }, - { - id: 'cat-2', - value: ( - - Cat 2 - - ), - }, - ]} - /> - - ), - footerMeta: ( - - Topic 1} - /> - - ), - children: ( - <> - Impedit commodi rerum -

- Omnis vel earum cupiditate delectus reprehenderit perferendis distinctio - omnis. Laudantium rem tempore eligendi porro officia est dolorum - assumenda. Corrupti tempore quia ab. Quidem est inventore. Autem - nesciunt sed rerum praesentium. -

-

- Illo nostrum inventore tenetur quo repellendus autem nisi nostrum - dolore. Et velit assumenda. Veniam harum officia et. Blanditiis et et - qui cum. Rerum illum quo doloribus neque non velit. Unde iusto et eaque - a ut. -

- Et omnis ducimus -

- Dolor quidem quas perferendis in nam molestiae. Accusamus quidem - accusantium quaerat est praesentium accusamus ab dolorem. Beatae illum - totam et corrupti assumenda corporis aut illo animi. -

-

- Ad rem soluta. Est tenetur consequatur sequi voluptates autem. Molestiae - in neque dignissimos. Dolorum numquam quos quam voluptas atque facilis - et. Accusantium fuga architecto excepturi consequatur libero est. -

- - ), - widgets: [ - Share} - media={[ - 'diaspora', - 'email', - 'facebook', - 'journal-du-hacker', - 'linkedin', - 'twitter', - ]} - />, - ], - withToC: true, - comments: [ - { - author: { - name: 'Milan0', - avatar: { - alt: 'Milan0 avatar', - src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/976.jpg', - }, - }, - content: 'Fugit veniam quas qui dolor explicabo.', - id: 1, - isApproved: true, - publicationDate: '2023-01-23', - replies: [ - { - author: { name: 'Haskell42' }, - content: 'Error quas accusamus nesciunt enim quae a.', - id: 25, - isApproved: true, - publicationDate: '2023-02-04', - }, - { - author: { - name: 'Hanna49', - website: 'https://www.armandphilippot.com', - }, - content: 'Ut ducimus neque aliquam soluta sed totam commodi cum sit.', - id: 30, - isApproved: true, - publicationDate: '2023-03-10', - }, - ], - }, - { - author: { - name: 'Corrine9', - avatar: { - alt: 'Corrine9 avatar', - src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/539.jpg', - }, - }, - content: - 'Dolore hic iure voluptatum quam error minima. Quas ut aperiam sit commodi cumque consequatur. Voluptas debitis veritatis officiis in voluptas ea et laborum animi. Voluptatem qui enim neque. Et sunt quo neque assumenda iure. Non vel ut consectetur.', - id: 2, - isApproved: true, - publicationDate: '2023-04-20', - }, - { - author: { name: 'Presley12' }, - content: - 'Nulla eaque similique recusandae enim aut eligendi iure consequatur. Et aut qui. Voluptatem a voluptatem consequatur aliquid distinctio ex culpa. Adipisci animi amet reprehenderit autem quia commodi voluptatum commodi.', - id: 3, - isApproved: true, - publicationDate: '2023-05-01', - replies: [ - { - author: { - name: 'Ana_Haley33', - avatar: { - alt: 'Ana_Haley33 avatar', - src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/881.jpg', - }, - }, - content: 'Ab ea et fugit autem.', - id: 17, - isApproved: true, - publicationDate: '2023-05-01', - }, - { - author: { name: 'Santos.Harris17' }, - content: - 'Illo dolores voluptatem similique voluptas quasi hic aspernatur ab nisi.', - id: 18, - isApproved: false, - publicationDate: '2023-05-02', - }, - ], - }, - { - author: { name: 'Julius.Borer' }, - content: - 'Ea fugit totam et voluptatum quidem laborum explicabo fuga quod.', - id: 4, - isApproved: true, - publicationDate: '2023-06-15', - }, - { - author: { name: 'Geo87' }, - content: - 'Enim consequatur deleniti aliquid adipisci. Et mollitia saepe vel rerum totam praesentium assumenda repellat fuga. Ipsum ut architecto consequatur. Ut laborum suscipit sed corporis quas aliquid. Et et omnis quo. Dolore quia ipsum ut corporis eum et corporis qui.', - id: 5, - isApproved: false, - publicationDate: '2023-06-16', - }, - { - author: { name: 'Kurt.Keeling' }, - content: 'Eligendi repellat officiis amet.', - id: 6, - isApproved: true, - publicationDate: '2023-06-17', - }, - ], - allowComments: true, -}; - -const postsListBreadcrumb = [ - { id: 'home', url: '#', name: 'Home' }, - { id: 'blog', url: '#', name: 'Blog' }, -]; - -const blogCategories = [ - { id: 'cat1', label: 'Cat 1', url: '#' }, - { id: 'cat2', label: 'Cat 2', url: '#' }, - { id: 'cat3', label: 'Cat 3', url: '#' }, - { id: 'cat4', label: 'Cat 4', url: '#' }, -]; - -const posts = [ - { - excerpt: - 'Omnis voluptatem et sit sit porro possimus quo rerum. Natus et sint cupiditate magnam omnis a consequuntur reprehenderit. Ex omnis voluptatem itaque id laboriosam qui dolorum facilis architecto. Impedit aliquid et qui quae dolorum accusamus rerum.', - heading: 'Post 1', - id: 'post1', - meta: { publicationDate: '2023-11-06' }, - url: '#post1', - }, - { - excerpt: - 'Nobis omnis excepturi deserunt laudantium unde totam quam. Voluptates maiores minima voluptatem nihil ea voluptatem similique. Praesentium ratione necessitatibus et et dolore voluptas illum dignissimos ipsum. Eius tempore ex.', - heading: 'Post 2', - id: 'post2', - meta: { publicationDate: '2023-11-05' }, - url: '#post2', - }, - { - excerpt: - 'Doloremque est dolorum explicabo. Laudantium quos delectus odit esse fugit officiis. Fugit provident vero harum atque. Eos nam qui sit ut minus voluptas. Reprehenderit rerum ut nostrum. Eos dolores mollitia quia ea voluptatem rerum vel.', - heading: 'Post 3', - id: 'post3', - meta: { publicationDate: '2023-11-04' }, - url: '#post3', - }, -]; - -/** - * Page Layout Stories - Posts list - */ -export const Blog = Template.bind({}); -Blog.args = { - breadcrumb: postsListBreadcrumb, - title: 'Blog', - headerMeta: ( - - - - ), - children: , - widgets: [ - - Categories - - } - items={blogCategories} - key="sidebar-widget1" - />, - ], -}; diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx deleted file mode 100644 index c7d7a65..0000000 --- a/src/components/templates/page/page-layout.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import type { BreadcrumbList } from 'schema-dts'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { PageLayout } from './page-layout'; - -const title = 'Incidunt ad earum'; -const breadcrumb = [ - { id: 'home', url: '#', name: 'Home' }, - { id: 'page', url: '#', name: title }, -]; -const breadcrumbSchema: BreadcrumbList['itemListElement'][] = []; -const children = - 'Reprehenderit aut quis aperiam magnam quia id. Vero enim animi placeat quia. Laborum sit odio minima. Dolores et debitis eaque iste quidem. Omnis aliquam illum porro ea non. Quaerat totam iste quos ex facilis officia accusantium.'; - -describe('PageLayout', () => { - it('renders the page title', () => { - render( - - {children} - - ); - expect( - rtlScreen.getByRole('heading', { level: 1, name: title }) - ).toBeInTheDocument(); - }); - - it('renders the page content', () => { - render( - - {children} - - ); - expect(rtlScreen.getByText(children)).toBeInTheDocument(); - }); - - it('renders the breadcrumb', () => { - render( - - {children} - - ); - expect( - rtlScreen.getByRole('navigation', { name: 'Breadcrumb' }) - ).toBeInTheDocument(); - }); - - it('renders the table of contents', () => { - render( - - {children} - - ); - expect(rtlScreen.getByText(/Table of Contents/i)).toBeInTheDocument(); - }); - - it('renders the comment form', () => { - render( - - {children} - - ); - expect( - rtlScreen.getByRole('form', { name: /Comment form/i }) - ).toBeInTheDocument(); - }); - - it('renders the comments list', () => { - render( - - {children} - - ); - expect( - rtlScreen.getByRole('heading', { level: 2, name: /Comments/i }) - ).toBeInTheDocument(); - }); -}); diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx deleted file mode 100644 index 75d308e..0000000 --- a/src/components/templates/page/page-layout.tsx +++ /dev/null @@ -1,287 +0,0 @@ -/* eslint-disable max-statements */ -import Script from 'next/script'; -import { - type FC, - type HTMLAttributes, - type ReactNode, - useCallback, -} from 'react'; -import { useIntl } from 'react-intl'; -import type { BreadcrumbList } from 'schema-dts'; -import { sendComment } from '../../../services/graphql'; -import type { SendCommentInput } from '../../../types'; -import { useHeadingsTree } from '../../../utils/hooks'; -import { Heading, Sidebar } from '../../atoms'; -import { PageFooter, PageHeader, type PageHeaderProps } from '../../molecules'; -import { - CommentForm, - CommentsList, - type CommentsListProps, - TocWidget, - Breadcrumbs, - type BreadcrumbsItem, - type CommentFormSubmit, -} from '../../organisms'; -import styles from './page-layout.module.scss'; - -export type PageLayoutProps = { - /** - * True if the page accepts new comments. Default: false. - */ - allowComments?: boolean; - /** - * Set attributes to the page body. - */ - bodyAttributes?: HTMLAttributes; - /** - * Set additional classnames to the body wrapper. - */ - bodyClassName?: string; - /** - * The breadcrumb items. - */ - breadcrumb: BreadcrumbsItem[]; - /** - * The breadcrumb JSON schema. - */ - breadcrumbSchema: BreadcrumbList['itemListElement'][]; - /** - * The main content of the page. - */ - children: ReactNode; - /** - * The page comments - */ - comments?: CommentsListProps['comments']; - /** - * The footer metadata. - */ - footerMeta?: ReactNode; - /** - * The header metadata. - */ - headerMeta?: ReactNode; - /** - * The page id. - */ - id?: number; - /** - * The page introduction. - */ - intro?: PageHeaderProps['intro']; - /** - * The page title. - */ - title: PageHeaderProps['title']; - /** - * An array of widgets to put in the last sidebar. - */ - widgets?: ReactNode[]; - /** - * Show the table of contents. Default: false. - */ - withToC?: boolean; -}; - -/** - * PageLayout component - * - * Render the pages layout. - */ -export const PageLayout: FC = ({ - children, - allowComments = false, - bodyAttributes, - bodyClassName = '', - breadcrumb, - breadcrumbSchema, - comments, - footerMeta, - headerMeta, - id, - intro, - title, - widgets, - withToC = false, -}) => { - const intl = useIntl(); - const breadcrumbsLabel = intl.formatMessage({ - defaultMessage: 'Breadcrumb', - description: 'PageLayout: an accessible name for the breadcrumb nav.', - id: 'm6a3BD', - }); - const commentsTitle = intl.formatMessage({ - defaultMessage: 'Comments', - description: 'PageLayout: comments title', - id: '+dJU3e', - }); - const commentFormSectionTitle = intl.formatMessage({ - defaultMessage: 'Leave a comment', - description: 'PageLayout: comment form title', - id: 'kzIYoQ', - }); - const commentFormTitle = intl.formatMessage({ - defaultMessage: 'Comment form', - description: 'PageLayout: comment form accessible name', - id: 'l+Jcf6', - }); - const tocTitle = intl.formatMessage({ - defaultMessage: 'Table of Contents', - description: 'PageLayout: table of contents title', - id: 'eys2uX', - }); - - const { ref: bodyRef, tree: headingsTree } = useHeadingsTree({ - fromLevel: 2, - }); - - const saveComment: CommentFormSubmit = useCallback( - async (data) => { - if (!id) throw new Error('Page id missing. Cannot save comment.'); - - const { author, comment: commentBody, email, parentId, website } = data; - const commentData: SendCommentInput = { - author, - authorEmail: email, - authorUrl: website ?? '', - clientMutationId: 'comment', - commentOn: id, - content: commentBody, - parent: parentId, - }; - const { comment, success } = await sendComment(commentData); - - if (success) { - const successPrefix = intl.formatMessage({ - defaultMessage: 'Thanks, your comment was successfully sent.', - description: 'PageLayout: comment form success message', - id: 'B290Ph', - }); - const successMessage = comment?.approved - ? intl.formatMessage({ - defaultMessage: 'It has been approved.', - id: 'g3+Ahv', - description: 'PageLayout: comment approved.', - }) - : intl.formatMessage({ - defaultMessage: 'It is now awaiting moderation.', - id: 'Vmj5cw', - description: 'PageLayout: comment awaiting moderation', - }); - return { - messages: { - success: `${successPrefix} ${successMessage}`, - }, - validator: () => success, - }; - } - - return { - messages: { - error: intl.formatMessage({ - defaultMessage: 'An error occurred:', - description: 'PageLayout: comment form error message', - id: 'fkcTGp', - }), - }, - validator: () => success, - }; - }, - [id, intl] - ); - - return ( - <> -