From c95cce04393080a52a54191cff7be8fec68af4b0 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Sun, 15 May 2022 17:45:41 +0200 Subject: chore: add Article pages --- src/components/atoms/layout/notice.module.scss | 1 - .../organisms/forms/comment-form.stories.tsx | 30 +-- .../organisms/forms/comment-form.test.tsx | 7 +- src/components/organisms/forms/comment-form.tsx | 19 +- .../organisms/layout/comment.stories.tsx | 39 +++- src/components/organisms/layout/comment.test.tsx | 6 +- src/components/organisms/layout/comment.tsx | 14 +- .../organisms/layout/comments-list.stories.tsx | 30 ++- .../organisms/layout/comments-list.test.tsx | 6 +- src/components/organisms/layout/comments-list.tsx | 10 +- .../templates/page/page-layout.stories.tsx | 6 +- src/components/templates/page/page-layout.tsx | 95 +++++++- src/pages/article/[slug].tsx | 251 +++++++++++++++++++++ src/services/graphql/api.ts | 52 ++++- src/services/graphql/comments.mutation.ts | 30 +++ src/services/graphql/comments.ts | 102 +++++++++ 16 files changed, 634 insertions(+), 64 deletions(-) create mode 100644 src/pages/article/[slug].tsx create mode 100644 src/services/graphql/comments.mutation.ts create mode 100644 src/services/graphql/comments.ts (limited to 'src') diff --git a/src/components/atoms/layout/notice.module.scss b/src/components/atoms/layout/notice.module.scss index 38ec7ee..7fd972c 100644 --- a/src/components/atoms/layout/notice.module.scss +++ b/src/components/atoms/layout/notice.module.scss @@ -1,7 +1,6 @@ @use "@styles/abstracts/functions" as fun; .wrapper { - width: max-content; padding: var(--spacing-2xs) var(--spacing-xs); border: fun.convert-px(2) solid; font-weight: bold; diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form.stories.tsx index f66d35c..8b11df7 100644 --- a/src/components/organisms/forms/comment-form.stories.tsx +++ b/src/components/organisms/forms/comment-form.stories.tsx @@ -1,13 +1,19 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { IntlProvider } from 'react-intl'; import CommentForm from './comment-form'; +const saveComment = async () => { + /** Do nothing. */ +}; + /** * CommentForm - Storybook Meta */ export default { title: 'Organisms/Forms', component: CommentForm, + args: { + saveComment, + }, argTypes: { className: { control: { @@ -35,6 +41,16 @@ export default { required: false, }, }, + parentId: { + control: { + type: null, + }, + description: 'The parent id if it is a reply.', + type: { + name: 'number', + required: false, + }, + }, saveComment: { control: { type: null, @@ -74,13 +90,6 @@ export default { }, }, }, - decorators: [ - (Story) => ( - - - - ), - ], } as ComponentMeta; const Template: ComponentStory = (args) => ( @@ -91,8 +100,3 @@ const Template: ComponentStory = (args) => ( * Forms Stories - Comment */ export const Comment = Template.bind({}); -Comment.args = { - saveComment: (reset: () => void) => { - reset(); - }, -}; diff --git a/src/components/organisms/forms/comment-form.test.tsx b/src/components/organisms/forms/comment-form.test.tsx index 0d387b5..c67ad6b 100644 --- a/src/components/organisms/forms/comment-form.test.tsx +++ b/src/components/organisms/forms/comment-form.test.tsx @@ -1,17 +1,20 @@ import { render, screen } from '@test-utils'; import CommentForm from './comment-form'; +const saveComment = async () => { + /** Do nothing. */ +}; const title = 'Cum voluptas voluptatibus'; describe('CommentForm', () => { it('renders a form', () => { - render( null} />); + render(); expect(screen.getByRole('form')).toBeInTheDocument(); }); it('renders an optional title', () => { render( - null} title={title} titleLevel={2} /> + ); expect( screen.getByRole('heading', { level: 2, name: title }) diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx index d7cb0f5..9e0abdf 100644 --- a/src/components/organisms/forms/comment-form.tsx +++ b/src/components/organisms/forms/comment-form.tsx @@ -7,6 +7,14 @@ import { FC, ReactNode, useState } from 'react'; import { useIntl } from 'react-intl'; import styles from './comment-form.module.scss'; +export type CommentFormData = { + comment: string; + email: string; + name: string; + parentId?: number; + website?: string; +}; + export type CommentFormProps = { /** * Set additional classnames to the form wrapper. @@ -16,11 +24,15 @@ export type CommentFormProps = { * Pass a component to print a success/error message. */ Notice?: ReactNode; + /** + * The comment parent id. + */ + parentId?: number; /** * A callback function to save comment. It takes a function as parameter to * reset the form. */ - saveComment: (reset: () => void) => void; + saveComment: (data: CommentFormData, reset: () => void) => Promise; /** * The form title. */ @@ -34,6 +46,7 @@ export type CommentFormProps = { const CommentForm: FC = ({ className = '', Notice, + parentId, saveComment, title, titleLevel = 2, @@ -95,7 +108,9 @@ const CommentForm: FC = ({ */ const submitHandler = () => { setIsSubmitting(true); - saveComment(resetForm); + saveComment({ comment, email, name, parentId, website }, resetForm).then( + () => setIsSubmitting(false) + ); }; return ( diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx index 3794b06..c31b77a 100644 --- a/src/components/organisms/layout/comment.stories.tsx +++ b/src/components/organisms/layout/comment.stories.tsx @@ -1,13 +1,19 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { IntlProvider } from 'react-intl'; import CommentComponent from './comment'; +const saveComment = async () => { + /** Do nothing. */ +}; + /** * Comment - Storybook Meta */ export default { title: 'Organisms/Layout', component: CommentComponent, + args: { + saveComment, + }, argTypes: { author: { description: 'The author data.', @@ -51,6 +57,29 @@ export default { required: true, }, }, + Notice: { + control: { + type: null, + }, + description: 'A component to display a success or error message.', + table: { + category: 'Options', + }, + type: { + name: 'function', + required: false, + }, + }, + parentId: { + control: { + type: null, + }, + description: 'The parent id if it is a reply.', + type: { + name: 'number', + required: false, + }, + }, publication: { description: 'The publication date.', type: { @@ -73,13 +102,6 @@ export default { }, }, }, - decorators: [ - (Story) => ( - - - - ), - ], } as ComponentMeta; const Template: ComponentStory = (args) => ( @@ -100,7 +122,6 @@ Comment.args = { '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.', id: 2, publication: '2021-04-03 23:04:24', - saveComment: () => null, // @ts-ignore - Needed because of the placeholder image. unoptimized: true, }; diff --git a/src/components/organisms/layout/comment.test.tsx b/src/components/organisms/layout/comment.test.tsx index 4961722..02a51dc 100644 --- a/src/components/organisms/layout/comment.test.tsx +++ b/src/components/organisms/layout/comment.test.tsx @@ -11,13 +11,15 @@ 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.'; const publication = '2021-04-03 23:04:24'; const id = 5; - +const saveComment = async () => { + /** Do nothing. */ +}; const data = { author, content, id, publication, - saveComment: () => null, + saveComment, }; const formattedDate = getFormattedDate(publication); diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx index 248efc2..6df393b 100644 --- a/src/components/organisms/layout/comment.tsx +++ b/src/components/organisms/layout/comment.tsx @@ -25,7 +25,7 @@ export type CommentAuthor = { url?: string; }; -export type CommentProps = { +export type CommentProps = Pick & { /** * The comment author data. */ @@ -50,10 +50,6 @@ export type CommentProps = { * The comment date and time separated with a space. */ publication: string; - /** - * A callback function to save comment form data. - */ - saveComment: CommentFormProps['saveComment']; }; /** @@ -66,6 +62,7 @@ const Comment: FC = ({ canReply = true, content, id, + Notice, parentId, publication, saveComment, @@ -169,7 +166,10 @@ const Comment: FC = ({ className={styles.date} groupClassName={styles.date__item} /> -
{content}
+
{canReply && (
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx new file mode 100644 index 0000000..6a47c16 --- /dev/null +++ b/src/pages/article/[slug].tsx @@ -0,0 +1,251 @@ +import ButtonLink from '@components/atoms/buttons/button-link'; +import Link from '@components/atoms/links/link'; +import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb'; +import Sharing from '@components/organisms/widgets/sharing'; +import PageLayout, { + type PageLayoutProps, +} from '@components/templates/page/page-layout'; +import { + getAllArticlesSlugs, + getArticleBySlug, +} from '@services/graphql/articles'; +import { getPostComments } from '@services/graphql/comments'; +import { type Article, type Comment } from '@ts/types/app'; +import { loadTranslation, type Messages } from '@utils/helpers/i18n'; +import useSettings from '@utils/hooks/use-settings'; +import { GetStaticPaths, GetStaticProps, NextPage } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import Script from 'next/script'; +import { ParsedUrlQuery } from 'querystring'; +import { useIntl } from 'react-intl'; +import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts'; +import useSWR from 'swr'; + +type ArticlePageProps = { + comments: Comment[]; + post: Article; + translation: Messages; +}; + +/** + * Article page. + */ +const ArticlePage: NextPage = ({ comments, post }) => { + const { content, id, intro, meta, slug, title } = post; + const { author, commentsCount, cover, dates, seo, thematics, topics } = meta; + const { data } = useSWR(() => id, getPostComments, { + fallbackData: comments, + }); + const intl = useIntl(); + const homeLabel = intl.formatMessage({ + defaultMessage: 'Home', + description: 'Breadcrumb: home label', + id: 'j5k9Fe', + }); + const blogLabel = intl.formatMessage({ + defaultMessage: 'Blog', + description: 'Breadcrumb: blog label', + id: 'Es52wh', + }); + const breadcrumb: BreadcrumbItem[] = [ + { id: 'home', name: homeLabel, url: '/' }, + { id: 'blog', name: blogLabel, url: '/blog' }, + { id: 'article', name: title, url: `/article/${slug}` }, + ]; + + const headerMeta: PageLayoutProps['headerMeta'] = { + author: author?.name, + publication: { date: dates.publication }, + update: dates.update ? { date: dates.update } : undefined, + thematics: + thematics && + thematics.map((thematic) => ( + + {thematic.name} + + )), + }; + + const footerMeta: PageLayoutProps['footerMeta'] = { + topics: + topics && + topics.map((topic) => { + return ( + + {topic.name} + + ); + }), + }; + + const { website } = useSettings(); + const { asPath } = useRouter(); + const pageUrl = `${website.url}${asPath}`; + const pagePublicationDate = new Date(dates.publication); + const pageUpdateDate = dates.update ? new Date(dates.update) : undefined; + + const webpageSchema: WebPage = { + '@id': `${pageUrl}`, + '@type': 'WebPage', + breadcrumb: { '@id': `${website.url}/#breadcrumb` }, + lastReviewed: dates.update, + name: seo.title, + description: seo.description, + reviewedBy: { '@id': `${website.url}/#branding` }, + url: `${pageUrl}`, + isPartOf: { + '@id': `${website.url}`, + }, + }; + + const blogSchema: Blog = { + '@id': `${website.url}/#blog`, + '@type': 'Blog', + blogPost: { '@id': `${website.url}/#article` }, + isPartOf: { + '@id': `${pageUrl}`, + }, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + }; + + const blogPostSchema: BlogPosting = { + '@id': `${website.url}/#article`, + '@type': 'BlogPosting', + name: title, + description: intro, + articleBody: content, + author: { '@id': `${website.url}/#branding` }, + commentCount: commentsCount, + copyrightYear: pagePublicationDate.getFullYear(), + creator: { '@id': `${website.url}/#branding` }, + dateCreated: pagePublicationDate.toISOString(), + dateModified: pageUpdateDate && pageUpdateDate.toISOString(), + datePublished: pagePublicationDate.toISOString(), + discussionUrl: `${pageUrl}/#comments`, + editor: { '@id': `${website.url}/#branding` }, + headline: title, + image: cover?.src, + inLanguage: website.locales.default, + isPartOf: { + '@id': `${website.url}/blog`, + }, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + mainEntityOfPage: { '@id': `${pageUrl}` }, + thumbnailUrl: cover?.src, + }; + + const schemaJsonLd: Graph = { + '@context': 'https://schema.org', + '@graph': [webpageSchema, blogSchema, blogPostSchema], + }; + + /** + * Convert the comments list to the right format. + * + * @param {Comment[]} list - The comments list. + * @returns {PageLayoutProps['comments']} - The formatted comments list. + */ + const getCommentsList = (list: Comment[]): PageLayoutProps['comments'] => { + return list.map((comment) => { + const { + content: commentBody, + id: commentId, + meta: commentMeta, + parentId, + replies, + } = comment; + const { author: commentAuthor, date } = commentMeta; + const { name, avatar, website: authorUrl } = commentAuthor; + + return { + author: { name, avatar: avatar!.src, url: authorUrl }, + content: commentBody, + id: commentId, + publication: date, + child: getCommentsList(replies), + parentId, + }; + }); + }; + + return ( + <> + + {seo.title} + + + + + + +