diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-12-01 13:26:44 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-12-01 17:23:19 +0100 |
| commit | dfdbf6cac1fe3719dc71e130129d28e04ba4e225 (patch) | |
| tree | f865bdad53cef95bdfb10fc04174a0173ab36f15 | |
| parent | 5b762b1b669454a89899c4bdf6008027d9615acf (diff) | |
refactor(pages): refine Thematic pages
* add a table of contents (however posts heading are
not included)
* rename posts list section title
* add a useThematic hook to refresh thematic contents
* add a useThematicLists hook to refresh thematics list
* add a `notIn` filter in thematics list fetcher to directly
remove unwanted thematics
* add Cypress tests
26 files changed, 446 insertions, 115 deletions
diff --git a/src/i18n/en.json b/src/i18n/en.json index 671e2b1..c449756 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -299,10 +299,6 @@ "defaultMessage": "Page not found", "description": "Error404Page: page title" }, - "LszkU6": { - "defaultMessage": "All posts in {thematicName}", - "description": "ThematicPage: posts list heading" - }, "MJLr6U": { "defaultMessage": "CV", "description": "SiteNavbar: main nav - cv link" @@ -555,6 +551,10 @@ "defaultMessage": "Reading time:", "description": "PageHeader: reading time label" }, + "jrRBeb": { + "defaultMessage": "Browse posts in {thematicName} thematic", + "description": "ThematicPage: posts list heading" + }, "kq+fzI": { "defaultMessage": "Cover of {pageTitle}", "description": "RecentPosts: card cover accessible name" @@ -627,6 +627,10 @@ "defaultMessage": "Discover search results for {query} on {websiteName}.", "description": "SearchPage: SEO - Meta description" }, + "rVoW4G": { + "defaultMessage": "Thematics are loading...", + "description": "ThematicPage: loading thematics message" + }, "s57FTB": { "defaultMessage": "Share", "description": "Article: sharing widget title" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index c8b4058..9e5754a 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -16,7 +16,7 @@ "description": "BlogPage: SEO - Page title" }, "/42Z0z": { - "defaultMessage": "Sujets liés", + "defaultMessage": "Sujets connexes", "description": "ThematicPage: related topics list widget title" }, "/EfcyW": { @@ -299,10 +299,6 @@ "defaultMessage": "Page non trouvée", "description": "Error404Page: page title" }, - "LszkU6": { - "defaultMessage": "Tous les articles dans {thematicName}", - "description": "ThematicPage: posts list heading" - }, "MJLr6U": { "defaultMessage": "CV", "description": "SiteNavbar: main nav - cv link" @@ -555,6 +551,10 @@ "defaultMessage": "Temps de lecture :", "description": "PageHeader: reading time label" }, + "jrRBeb": { + "defaultMessage": "Parcourir les articles de la thématique {thematicName}", + "description": "ThematicPage: posts list heading" + }, "kq+fzI": { "defaultMessage": "Illustration de {pageTitle}", "description": "RecentPosts: card cover accessible name" @@ -627,6 +627,10 @@ "defaultMessage": "Découvrez les résultats de recherche pour {query} sur {websiteName}.", "description": "SearchPage: SEO - Meta description" }, + "rVoW4G": { + "defaultMessage": "Les thématiques sont en cours de chargement…", + "description": "ThematicPage: loading thematics message" + }, "s57FTB": { "defaultMessage": "Partager", "description": "Article: sharing widget title" diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx index 483df48..185756b 100644 --- a/src/pages/sujet/[slug].tsx +++ b/src/pages/sujet/[slug].tsx @@ -97,7 +97,7 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ const getPageHeading = () => ( <> - {cover ? <NextImage {...cover} className={styles.logo} /> : null} + {cover ? <NextImage {...cover} className={styles['topic-logo']} /> : null} {title} </> ); diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx index 487b18b..e290782 100644 --- a/src/pages/thematique/[slug].tsx +++ b/src/pages/thematique/[slug].tsx @@ -14,17 +14,24 @@ import { PageHeader, PageSidebar, PageBody, + LoadingPage, + TocWidget, + Spinner, } from '../../components'; import { convertWPThematicPreviewToPageLink, - convertWPThematicToThematic, fetchAllThematicsSlugs, fetchThematic, fetchThematicsCount, fetchThematicsList, } from '../../services/graphql'; import styles from '../../styles/pages/blog.module.scss'; -import type { NextPageWithLayout, PageLink, Thematic } from '../../types'; +import type { + GraphQLConnection, + NextPageWithLayout, + WPThematic, + WPThematicPreview, +} from '../../types'; import { CONFIG } from '../../utils/config'; import { ROUTES } from '../../utils/constants'; import { @@ -33,29 +40,47 @@ import { getSchemaJson, getSinglePageSchema, getWebPageSchema, + slugify, } from '../../utils/helpers'; import { loadTranslation, type Messages } from '../../utils/helpers/server'; -import { useBreadcrumb } from '../../utils/hooks'; +import { + useBreadcrumb, + useHeadingsTree, + useThematic, + useThematicsList, +} from '../../utils/hooks'; export type ThematicPageProps = { - currentThematic: Thematic; - thematics: PageLink[]; + data: { + currentThematic: WPThematic; + otherThematics: GraphQLConnection<WPThematicPreview>; + totalThematics: number; + }; translation: Messages; }; -const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ - currentThematic, - thematics, -}) => { - const { content, intro, meta, slug, title } = currentThematic; - const { articles, dates, seo, relatedTopics } = meta; +const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ data }) => { const intl = useIntl(); + const { isFallback } = useRouter(); + const { isLoading, thematic } = useThematic( + data.currentThematic.slug, + data.currentThematic + ); + const { isLoading: areThematicsLoading, thematics } = useThematicsList({ + fallback: data.otherThematics, + input: { first: data.totalThematics, where: { notIn: [thematic.id] } }, + }); const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ - title, - url: `${ROUTES.THEMATICS}/${slug}`, + title: data.currentThematic.title, + url: `${ROUTES.THEMATICS}/${data.currentThematic.slug}`, }); + const { ref, tree } = useHeadingsTree({ fromLevel: 2 }); + + if (isFallback || isLoading) return <LoadingPage />; + + const { content, intro, meta, slug, title } = thematic; + const { articles, dates, seo, relatedTopics } = meta; - const { asPath } = useRouter(); const webpageSchema = getWebPageSchema({ description: seo.description, locale: CONFIG.locales.defaultLocale, @@ -74,18 +99,41 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ }); const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]); - const thematicsListTitle = intl.formatMessage({ - defaultMessage: 'Other thematics', - description: 'ThematicPage: other thematics list widget title', - id: 'KVSWGP', - }); + const messages = { + widgets: { + loadingThematicsList: intl.formatMessage({ + defaultMessage: 'Thematics are loading...', + description: 'ThematicPage: loading thematics message', + id: 'rVoW4G', + }), + thematicsListTitle: intl.formatMessage({ + defaultMessage: 'Other thematics', + description: 'ThematicPage: other thematics list widget title', + id: 'KVSWGP', + }), + tocTitle: intl.formatMessage({ + defaultMessage: 'Table of Contents', + description: 'PageLayout: table of contents title', + id: 'eys2uX', + }), + topicsListTitle: intl.formatMessage({ + defaultMessage: 'Related topics', + description: 'ThematicPage: related topics list widget title', + id: '/42Z0z', + }), + }, + browsePostsTitle: intl.formatMessage( + { + defaultMessage: 'Browse posts in {thematicName} thematic', + description: 'ThematicPage: posts list heading', + id: 'jrRBeb', + }, + { thematicName: title } + ), + }; - const topicsListTitle = intl.formatMessage({ - defaultMessage: 'Related topics', - description: 'ThematicPage: related topics list widget title', - id: '/42Z0z', - }); - const pageUrl = `${CONFIG.url}${asPath}`; + const pageUrl = `${CONFIG.url}${slug}`; + const browsePostHeadingId = slugify(messages.browsePostsTitle); return ( <Page breadcrumbs={breadcrumbItems}> @@ -121,23 +169,33 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ updateDate: dates.update, }} /> - <PageBody className={styles.body}> - {/*eslint-disable-next-line react/no-danger -- Necessary for content*/} - <div dangerouslySetInnerHTML={{ __html: content }} /> + <PageSidebar> + <TocWidget + heading={<Heading level={2}>{messages.widgets.tocTitle}</Heading>} + tree={[ + ...tree, + { + children: [], + depth: 2, + id: browsePostHeadingId, + label: messages.browsePostsTitle, + }, + ]} + /> + </PageSidebar> + <PageBody> + <div + className={styles.body} + // eslint-disable-next-line react/no-danger -- Necessary for content + dangerouslySetInnerHTML={{ __html: content }} + ref={ref} + /> {articles ? ( <> - <Heading level={2}> - {intl.formatMessage( - { - defaultMessage: 'All posts in {thematicName}', - description: 'ThematicPage: posts list heading', - id: 'LszkU6', - }, - { thematicName: title } - )} + <Heading id={browsePostHeadingId} level={2}> + {messages.browsePostsTitle} </Heading> <PostsList - className={styles.list} posts={getPostsWithUrl(articles)} headingLvl={3} sortByYear @@ -146,20 +204,24 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ ) : null} </PageBody> <PageSidebar> - <LinksWidget - heading={ - <Heading isFake level={3}> - {thematicsListTitle} - </Heading> - } - items={getLinksItemData(thematics)} - /> + {areThematicsLoading ? ( + <Spinner>{messages.widgets.loadingThematicsList}</Spinner> + ) : ( + <LinksWidget + heading={ + <Heading level={2}>{messages.widgets.thematicsListTitle}</Heading> + } + items={getLinksItemData( + thematics.edges.map((edge) => + convertWPThematicPreviewToPageLink(edge.node) + ) + )} + /> + )} {relatedTopics ? ( <LinksWidget heading={ - <Heading isFake level={3}> - {topicsListTitle} - </Heading> + <Heading level={2}>{messages.widgets.topicsListTitle}</Heading> } items={getLinksItemData(relatedTopics)} /> @@ -179,26 +241,21 @@ export const getStaticProps: GetStaticProps<ThematicPageProps> = async ({ locale, params, }) => { - const currentThematic = await fetchThematic((params as ThematicParams).slug); + const thematic = await fetchThematic((params as ThematicParams).slug); const totalThematics = await fetchThematicsCount(); - const allThematicsEdges = await fetchThematicsList({ + const otherThematics = await fetchThematicsList({ first: totalThematics, + where: { notIn: [thematic.databaseId] }, }); - const allThematics = allThematicsEdges.edges.map((edge) => - convertWPThematicPreviewToPageLink(edge.node) - ); - const allThematicsLinks = allThematics.filter( - (thematic) => - thematic.url !== `${ROUTES.THEMATICS}/${(params as ThematicParams).slug}` - ); const translation = await loadTranslation(locale); return { props: { - currentThematic: JSON.parse( - JSON.stringify(convertWPThematicToThematic(currentThematic)) - ), - thematics: JSON.parse(JSON.stringify(allThematicsLinks)), + data: { + currentThematic: JSON.parse(JSON.stringify(thematic)), + otherThematics: JSON.parse(JSON.stringify(otherThematics)), + totalThematics, + }, translation, }, }; diff --git a/src/services/graphql/fetchers/thematics/fetch-thematics-list.ts b/src/services/graphql/fetchers/thematics/fetch-thematics-list.ts index 7e1e582..5dff5e1 100644 --- a/src/services/graphql/fetchers/thematics/fetch-thematics-list.ts +++ b/src/services/graphql/fetchers/thematics/fetch-thematics-list.ts @@ -12,13 +12,13 @@ export type ThematicsListResponse = { thematics: Nullable<GraphQLConnection<WPThematicPreview>>; }; -const thematicsListQuery = `query ThematicsList($after: String, $before: String, $first: Int, $last: Int, $orderby: [PostObjectsConnectionOrderbyInput], $search: String, $title: String) { +const thematicsListQuery = `query ThematicsList($after: String, $before: String, $first: Int, $last: Int, $orderby: [PostObjectsConnectionOrderbyInput], $search: String, $title: String, $notIn: [ID]) { thematics( after: $after before: $before first: $first last: $last - where: {orderby: $orderby, search: $search, title: $title} + where: {orderby: $orderby, search: $search, title: $title, notIn: $notIn} ) { edges { cursor diff --git a/src/services/graphql/fetchers/topics/fetch-topics-list.ts b/src/services/graphql/fetchers/topics/fetch-topics-list.ts index 2ede721..6f2ab8f 100644 --- a/src/services/graphql/fetchers/topics/fetch-topics-list.ts +++ b/src/services/graphql/fetchers/topics/fetch-topics-list.ts @@ -12,13 +12,13 @@ export type TopicsListResponse = { topics: Nullable<GraphQLConnection<WPTopicPreview>>; }; -const topicsListQuery = `query TopicsList($after: String, $before: String, $first: Int, $last: Int, $orderby: [PostObjectsConnectionOrderbyInput], $search: String, $title: String) { +const topicsListQuery = `query TopicsList($after: String, $before: String, $first: Int, $last: Int, $orderby: [PostObjectsConnectionOrderbyInput], $search: String, $title: String, $notIn: [ID]) { topics( after: $after before: $before first: $first last: $last - where: {orderby: $orderby, search: $search, title: $title} + where: {orderby: $orderby, search: $search, title: $title, notIn: $notIn} ) { edges { cursor diff --git a/src/services/graphql/helpers/convert-wp-thematic-to-thematic.test.ts b/src/services/graphql/helpers/convert-wp-thematic-to-thematic.test.ts index 435489d..140e165 100644 --- a/src/services/graphql/helpers/convert-wp-thematic-to-thematic.test.ts +++ b/src/services/graphql/helpers/convert-wp-thematic-to-thematic.test.ts @@ -34,6 +34,7 @@ describe('convert-wp-thematic-to-thematic', () => { const result = convertWPThematicToThematic(thematic); expect(result.content).toBe(thematic.contentParts.afterMore); + expect(result.id).toBe(thematic.databaseId); expect(result.intro).toBe(thematic.contentParts.beforeMore); expect(result.meta.articles).toBeUndefined(); expect(result.meta.cover).toBeUndefined(); diff --git a/src/services/graphql/helpers/convert-wp-thematic-to-thematic.ts b/src/services/graphql/helpers/convert-wp-thematic-to-thematic.ts index 9aa1896..5f8d7fc 100644 --- a/src/services/graphql/helpers/convert-wp-thematic-to-thematic.ts +++ b/src/services/graphql/helpers/convert-wp-thematic-to-thematic.ts @@ -8,6 +8,7 @@ import { ROUTES } from '../../../utils/constants'; import { getUniquePageLinks, sortPageLinksByName, + updateContentTree, } from '../../../utils/helpers'; import { convertPostPreviewToArticlePreview } from './convert-post-preview-to-article-preview'; import { convertWPTopicPreviewToPageLink } from './convert-taxonomy-to-page-link'; @@ -31,30 +32,39 @@ const getRelatedTopicsFrom = (posts: WPPostPreview[]): PageLink[] => { return getUniquePageLinks(topics).sort(sortPageLinksByName); }; -export const convertWPThematicToThematic = (thematic: WPThematic): Thematic => { +export const convertWPThematicToThematic = ({ + acfThematics, + contentParts, + databaseId, + date, + featuredImage, + modified, + seo, + slug, + title, +}: WPThematic): Thematic => { return { - content: thematic.contentParts.afterMore, - intro: thematic.contentParts.beforeMore, + content: updateContentTree(contentParts.afterMore), + id: databaseId, + intro: contentParts.beforeMore, meta: { - articles: thematic.acfThematics?.postsInThematic?.map( + articles: acfThematics?.postsInThematic?.map( convertPostPreviewToArticlePreview ), - cover: thematic.featuredImage - ? convertWPImgToImg(thematic.featuredImage.node) - : undefined, + cover: featuredImage ? convertWPImgToImg(featuredImage.node) : undefined, dates: { - publication: thematic.date, - update: thematic.modified, + publication: date, + update: modified, }, seo: { - description: thematic.seo.metaDesc, - title: thematic.seo.title, + description: seo.metaDesc, + title: seo.title, }, - relatedTopics: thematic.acfThematics?.postsInThematic - ? getRelatedTopicsFrom(thematic.acfThematics.postsInThematic) + relatedTopics: acfThematics?.postsInThematic + ? getRelatedTopicsFrom(acfThematics.postsInThematic) : undefined, }, - slug: `${ROUTES.THEMATICS}/${thematic.slug}`, - title: thematic.title, + slug: `${ROUTES.THEMATICS}/${slug}`, + title, }; }; diff --git a/src/services/graphql/helpers/convert-wp-topic-to-topic.test.ts b/src/services/graphql/helpers/convert-wp-topic-to-topic.test.ts index bfe2ba9..145b19d 100644 --- a/src/services/graphql/helpers/convert-wp-topic-to-topic.test.ts +++ b/src/services/graphql/helpers/convert-wp-topic-to-topic.test.ts @@ -34,6 +34,7 @@ describe('convert-wp-topic-to-topic', () => { const result = convertWPTopicToTopic(topic); expect(result.content).toBe(topic.contentParts.afterMore); + expect(result.id).toBe(topic.databaseId); expect(result.intro).toBe(topic.contentParts.beforeMore); expect(result.meta.articles).toBeUndefined(); expect(result.meta.cover).toBeUndefined(); diff --git a/src/services/graphql/helpers/convert-wp-topic-to-topic.ts b/src/services/graphql/helpers/convert-wp-topic-to-topic.ts index b0136c7..f3ed4a9 100644 --- a/src/services/graphql/helpers/convert-wp-topic-to-topic.ts +++ b/src/services/graphql/helpers/convert-wp-topic-to-topic.ts @@ -26,31 +26,40 @@ const getRelatedThematicsFrom = (posts: WPPostPreview[]): PageLink[] => { return getUniquePageLinks(thematics).sort(sortPageLinksByName); }; -export const convertWPTopicToTopic = (topic: WPTopic): Topic => { +export const convertWPTopicToTopic = ({ + acfTopics, + contentParts, + databaseId, + date, + featuredImage, + modified, + seo, + slug, + title, +}: WPTopic): Topic => { return { - content: topic.contentParts.afterMore, - intro: topic.contentParts.beforeMore, + content: contentParts.afterMore, + id: databaseId, + intro: contentParts.beforeMore, meta: { - articles: topic.acfTopics?.postsInTopic?.map( + articles: acfTopics?.postsInTopic?.map( convertPostPreviewToArticlePreview ), - cover: topic.featuredImage - ? convertWPImgToImg(topic.featuredImage.node) - : undefined, + cover: featuredImage ? convertWPImgToImg(featuredImage.node) : undefined, dates: { - publication: topic.date, - update: topic.modified, + publication: date, + update: modified, }, seo: { - description: topic.seo.metaDesc, - title: topic.seo.title, + description: seo.metaDesc, + title: seo.title, }, - relatedThematics: topic.acfTopics?.postsInTopic - ? getRelatedThematicsFrom(topic.acfTopics.postsInTopic) + relatedThematics: acfTopics?.postsInTopic + ? getRelatedThematicsFrom(acfTopics.postsInTopic) : undefined, - website: topic.acfTopics?.officialWebsite ?? undefined, + website: acfTopics?.officialWebsite ?? undefined, }, - slug: `${ROUTES.TOPICS}/${topic.slug}`, - title: topic.title, + slug: `${ROUTES.TOPICS}/${slug}`, + title, }; }; diff --git a/src/styles/abstracts/placeholders/_links.scss b/src/styles/abstracts/placeholders/_links.scss index a230e70..9bfd19e 100644 --- a/src/styles/abstracts/placeholders/_links.scss +++ b/src/styles/abstracts/placeholders/_links.scss @@ -29,6 +29,8 @@ } %link-with-icon { + display: inline-block; + &::after { display: inline-block; content: var(--is-lang-hidden, "\0000a0" var(--lang-icon, "")) diff --git a/src/styles/pages/blog.module.scss b/src/styles/pages/blog.module.scss index aebf263..d1819cd 100644 --- a/src/styles/pages/blog.module.scss +++ b/src/styles/pages/blog.module.scss @@ -7,7 +7,7 @@ @use "partials/article-media"; @use "partials/article-wp-blocks"; -.list { +.posts-list { @include mix.media("screen") { @include mix.dimensions("md") { --col1: #{fun.convert-px(100)}; @@ -28,7 +28,7 @@ } } -.logo { +.topic-logo { max-width: fun.convert-px(50); margin: 0 var(--spacing-xs) 0 0; } diff --git a/src/types/data.ts b/src/types/data.ts index 21f773e..80a8bf3 100644 --- a/src/types/data.ts +++ b/src/types/data.ts @@ -273,6 +273,7 @@ export type ThematicMeta = Omit<PageMeta, 'wordsCount'> & { }; export type Thematic = Page & { + id: number; meta: ThematicMeta; }; @@ -283,5 +284,6 @@ export type TopicMeta = Omit<PageMeta, 'wordsCount'> & { }; export type Topic = Page & { + id: number; meta: TopicMeta; }; diff --git a/src/types/gql.ts b/src/types/gql.ts index cec66c6..ac3ac36 100644 --- a/src/types/gql.ts +++ b/src/types/gql.ts @@ -70,4 +70,5 @@ export type GraphQLTaxonomyOrderBy = { export type GraphQLTaxonomyWhere = { search?: string; title?: string; + notIn?: number[]; }; diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index f3bfd75..3fb0ad4 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -22,6 +22,8 @@ export * from './use-scroll-lock'; export * from './use-scroll-position'; export * from './use-scrollbar-width'; export * from './use-system-color-scheme'; +export * from './use-thematic'; +export * from './use-thematics-list'; export * from './use-theme'; export * from './use-timeout'; export * from './use-toggle'; diff --git a/src/utils/hooks/use-thematic/index.ts b/src/utils/hooks/use-thematic/index.ts new file mode 100644 index 0000000..319f4b5 --- /dev/null +++ b/src/utils/hooks/use-thematic/index.ts @@ -0,0 +1 @@ +export * from './use-thematic'; diff --git a/src/utils/hooks/use-thematic/use-thematic.test.ts b/src/utils/hooks/use-thematic/use-thematic.test.ts new file mode 100644 index 0000000..43d0a57 --- /dev/null +++ b/src/utils/hooks/use-thematic/use-thematic.test.ts @@ -0,0 +1,56 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { renderHook, waitFor } from '@testing-library/react'; +import { wpThematicsFixture } from '../../../../tests/fixtures'; +import { ROUTES } from '../../constants'; +import { useThematic } from './use-thematic'; + +describe('useThematic', () => { + beforeEach(() => { + /* Not sure why it is needed, but without it Jest was complaining with + * `Jest worker encountered 4 child process exceptions`... Maybe because of + * useSWR? */ + jest.useFakeTimers({ + doNotFake: ['queueMicrotask'], + }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + /* eslint-disable max-statements */ + it('fetch the requested thematic', async () => { + const { result } = renderHook(() => + useThematic(wpThematicsFixture[0].slug) + ); + + // Inaccurate assertions count because of waitFor... + //expect.assertions(8); + expect.hasAssertions(); + + expect(result.current.thematic).toBeUndefined(); + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(true); + expect(result.current.isValidating).toBe(true); + + jest.advanceTimersToNextTimer(); + + await waitFor(() => + expect(result.current.thematic?.slug).toBe( + `${ROUTES.THEMATICS}/${wpThematicsFixture[0].slug}` + ) + ); + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.isValidating).toBe(false); + }); + /* eslint-enable max-statements */ +}); diff --git a/src/utils/hooks/use-thematic/use-thematic.ts b/src/utils/hooks/use-thematic/use-thematic.ts new file mode 100644 index 0000000..68127d2 --- /dev/null +++ b/src/utils/hooks/use-thematic/use-thematic.ts @@ -0,0 +1,31 @@ +import useSWR from 'swr'; +import { + convertWPThematicToThematic, + fetchThematic, +} from '../../../services/graphql'; +import type { Maybe, Thematic, WPThematic } from '../../../types'; + +export type UseThematicReturn<T extends Maybe<WPThematic>> = { + isError: boolean; + isLoading: boolean; + isValidating: boolean; + thematic: T extends undefined ? Maybe<Thematic> : Thematic; +}; + +export const useThematic = <T extends Maybe<WPThematic>>( + slug: string, + fallback?: T +): UseThematicReturn<T> => { + const { data, error, isLoading, isValidating } = useSWR(slug, fetchThematic, { + fallbackData: fallback, + }); + + if (error) console.error(error); + + return { + isError: !!error, + isLoading, + isValidating, + thematic: data ? convertWPThematicToThematic(data) : undefined, + } as UseThematicReturn<T>; +}; diff --git a/src/utils/hooks/use-thematics-list/index.ts b/src/utils/hooks/use-thematics-list/index.ts new file mode 100644 index 0000000..a886017 --- /dev/null +++ b/src/utils/hooks/use-thematics-list/index.ts @@ -0,0 +1 @@ +export * from './use-thematics-list'; diff --git a/src/utils/hooks/use-thematics-list/use-thematics-list.test.ts b/src/utils/hooks/use-thematics-list/use-thematics-list.test.ts new file mode 100644 index 0000000..0e19c2d --- /dev/null +++ b/src/utils/hooks/use-thematics-list/use-thematics-list.test.ts @@ -0,0 +1,48 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useThematicsList } from './use-thematics-list'; + +describe('useThematicsList', () => { + beforeEach(() => { + /* Not sure why it is needed, but without it Jest was complaining with + * `Jest worker encountered 4 child process exceptions`... Maybe because of + * useSWR? */ + jest.useFakeTimers({ + doNotFake: ['queueMicrotask'], + }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + /* eslint-disable max-statements */ + it('fetch the requested thematics list', async () => { + const { result } = renderHook(() => useThematicsList()); + + // Inaccurate assertions count because of waitFor... + //expect.assertions(8); + expect.hasAssertions(); + + expect(result.current.thematics).toBeUndefined(); + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(true); + expect(result.current.isValidating).toBe(true); + + jest.advanceTimersToNextTimer(); + + await waitFor(() => expect(result.current.thematics).toBeDefined()); + expect(result.current.isError).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.isValidating).toBe(false); + }); + /* eslint-enable max-statements */ +}); diff --git a/src/utils/hooks/use-thematics-list/use-thematics-list.ts b/src/utils/hooks/use-thematics-list/use-thematics-list.ts new file mode 100644 index 0000000..f63815a --- /dev/null +++ b/src/utils/hooks/use-thematics-list/use-thematics-list.ts @@ -0,0 +1,50 @@ +import useSWR from 'swr'; +import { + type FetchThematicsListInput, + fetchThematicsList, +} from '../../../services/graphql'; +import type { + GraphQLConnection, + Maybe, + WPThematicPreview, +} from '../../../types'; + +export type UseThematicsListReturn< + T extends Maybe<GraphQLConnection<WPThematicPreview>>, +> = { + isError: boolean; + isLoading: boolean; + isValidating: boolean; + thematics: T extends undefined + ? Maybe<GraphQLConnection<WPThematicPreview>> + : GraphQLConnection<WPThematicPreview>; +}; + +export type UseThematicsListConfig< + T extends Maybe<GraphQLConnection<WPThematicPreview>>, +> = { + input?: FetchThematicsListInput; + fallback?: T; +}; + +export const useThematicsList = < + T extends Maybe<GraphQLConnection<WPThematicPreview>>, +>( + config?: UseThematicsListConfig<T> +): UseThematicsListReturn<T> => { + const { fallback, input } = config ?? {}; + const { data, error, isLoading, isValidating } = useSWR( + input ?? {}, + fetchThematicsList, + { fallbackData: fallback } + ); + + if (error) console.error(error); + + return { + isError: !!error, + isLoading, + isValidating, + thematics: data, + } as UseThematicsListReturn<T>; +}; diff --git a/tests/cypress/e2e/pages/thematic.cy.ts b/tests/cypress/e2e/pages/thematic.cy.ts new file mode 100644 index 0000000..dab0d45 --- /dev/null +++ b/tests/cypress/e2e/pages/thematic.cy.ts @@ -0,0 +1,41 @@ +import { ROUTES } from '../../../../src/utils/constants'; + +describe('Thematic', () => { + beforeEach(() => { + cy.visit(ROUTES.HOME); + cy.findByRole('link', { name: /^Développement/i }).click(); + }); + + it('successfully loads', () => { + cy.findByRole('heading', { level: 1 }).should('exist'); + }); + + it('contains the thematic meta', () => { + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + cy.findAllByRole('term').should('have.length.at.least', 2); + + /* The accessible name is not recognized while it should be the `dt` text + * content */ + /* cy.findByRole('term', { name: 'Publié le :' }).should('exist'); + cy.findByRole('term', { name: 'Total :' }).should('exist'); */ + }); + + it('contains a breadcrumbs', () => { + cy.findByRole('navigation', { name: 'Fil d’Ariane' }).should('exist'); + }); + + it('contains a table of contents', () => { + cy.findByRole('heading', { level: 2, name: 'Table des matières' }).should( + 'exist' + ); + }); + + it('contains a thematics list widget and a topics list widget', () => { + cy.findByRole('heading', { level: 2, name: 'Autres thématiques' }).should( + 'exist' + ); + cy.findByRole('heading', { level: 2, name: 'Sujets connexes' }).should( + 'exist' + ); + }); +}); diff --git a/tests/msw/handlers/thematics/thematics-list.handler.ts b/tests/msw/handlers/thematics/thematics-list.handler.ts index f206247..7afec4c 100644 --- a/tests/msw/handlers/thematics/thematics-list.handler.ts +++ b/tests/msw/handlers/thematics/thematics-list.handler.ts @@ -23,12 +23,17 @@ export const thematicsListHandler = graphql.query< variableValues: variables, rootValue: { thematics({ after, first, where }: typeof variables) { - const { search, title } = where ?? {}; + const { notIn, search, title } = where ?? {}; + const filteredThematicsById = notIn + ? wpThematicsFixture.filter( + (thematic) => !notIn.includes(thematic.databaseId) + ) + : wpThematicsFixture; const filteredThematicsByTitle = title - ? wpThematicsFixture.filter((thematic) => + ? filteredThematicsById.filter((thematic) => thematic.title.includes(title) ) - : wpThematicsFixture; + : filteredThematicsById; const filteredThematics = search ? filteredThematicsByTitle.filter((thematic) => thematic.title.includes(search) diff --git a/tests/msw/handlers/topics/topics-list.handler.ts b/tests/msw/handlers/topics/topics-list.handler.ts index 5e3e31a..4b09c5a 100644 --- a/tests/msw/handlers/topics/topics-list.handler.ts +++ b/tests/msw/handlers/topics/topics-list.handler.ts @@ -23,10 +23,13 @@ export const topicsListHandler = graphql.query< variableValues: variables, rootValue: { topics({ after, first, where }: typeof variables) { - const { search, title } = where ?? {}; - const filteredTopicsByTitle = title - ? wpTopicsFixture.filter((topic) => topic.title.includes(title)) + const { notIn, search, title } = where ?? {}; + const filteredTopicsById = notIn + ? wpTopicsFixture.filter((topic) => !notIn.includes(topic.databaseId)) : wpTopicsFixture; + const filteredTopicsByTitle = title + ? filteredTopicsById.filter((topic) => topic.title.includes(title)) + : filteredTopicsById; const filteredTopics = search ? filteredTopicsByTitle.filter((topic) => topic.title.includes(search) diff --git a/tests/msw/schema/types/thematic.types.ts b/tests/msw/schema/types/thematic.types.ts index 2af4f9a..4f8e841 100644 --- a/tests/msw/schema/types/thematic.types.ts +++ b/tests/msw/schema/types/thematic.types.ts @@ -26,6 +26,7 @@ type Thematic { input RootQueryToThematicConnectionWhereArgs { authorName: String + notIn: [ID] orderby: [PostObjectsConnectionOrderbyInput] search: String title: String diff --git a/tests/msw/schema/types/topic.types.ts b/tests/msw/schema/types/topic.types.ts index 2d54653..7d6e4a0 100644 --- a/tests/msw/schema/types/topic.types.ts +++ b/tests/msw/schema/types/topic.types.ts @@ -27,6 +27,7 @@ type Topic { input RootQueryToTopicConnectionWhereArgs { authorName: String + notIn: [ID] orderby: [PostObjectsConnectionOrderbyInput] search: String title: String |
