aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-12-01 17:59:30 +0100
committerArmand Philippot <git@armandphilippot.com>2023-12-01 18:06:46 +0100
commit11e3ee75fcab0ab54b2bc1713a402c5cc3070c2d (patch)
tree7cb478ac6b29f2b527eb3ec379b305b74dd4f0ba
parentdfdbf6cac1fe3719dc71e130129d28e04ba4e225 (diff)
refactor(pages): refine Topic pages
* add useTopic and useTopicsList hooks to refresh data * add a table of contents * add Cypress tests
-rw-r--r--src/components/organisms/posts-list/posts-list.tsx14
-rw-r--r--src/i18n/en.json12
-rw-r--r--src/i18n/fr.json14
-rw-r--r--src/pages/sujet/[slug].tsx202
-rw-r--r--src/services/graphql/helpers/convert-wp-topic-to-topic.ts3
-rw-r--r--src/styles/pages/blog.module.scss11
-rw-r--r--src/utils/hooks/index.ts2
-rw-r--r--src/utils/hooks/use-topic/index.ts1
-rw-r--r--src/utils/hooks/use-topic/use-topic.test.ts54
-rw-r--r--src/utils/hooks/use-topic/use-topic.ts28
-rw-r--r--src/utils/hooks/use-topics-list/index.ts1
-rw-r--r--src/utils/hooks/use-topics-list/use-topics-list.test.ts48
-rw-r--r--src/utils/hooks/use-topics-list/use-topics-list.ts46
-rw-r--r--tests/cypress/e2e/pages/topic.cy.ts38
14 files changed, 380 insertions, 94 deletions
diff --git a/src/components/organisms/posts-list/posts-list.tsx b/src/components/organisms/posts-list/posts-list.tsx
index 783bc4e..c4c6fa1 100644
--- a/src/components/organisms/posts-list/posts-list.tsx
+++ b/src/components/organisms/posts-list/posts-list.tsx
@@ -40,10 +40,22 @@ export type PostData = Pick<
Required<Pick<PostPreviewMetaData, 'publicationDate'>>;
};
+/**
+ * Method to sort PageLink objects by name.
+ *
+ * @param {PageLink} a - A PageLink object.
+ * @param {PageLink} b - Another PageLink object.
+ * @returns {1 | -1 | 0}
+ */
+export const sortPostsByDate = (a: PostData, b: PostData) =>
+ new Date(b.meta.publicationDate).getTime() -
+ new Date(a.meta.publicationDate).getTime();
+
const getPostsByYear = (posts: PostData[]) => {
const yearCollection = new Map<string, PostData[]>();
+ const sortedPosts = [...posts].sort(sortPostsByDate);
- for (const post of posts) {
+ for (const post of sortedPosts) {
const currentPostYear = new Date(post.meta.publicationDate)
.getFullYear()
.toString();
diff --git a/src/i18n/en.json b/src/i18n/en.json
index c449756..f760860 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -499,6 +499,10 @@
"defaultMessage": "Popularity:",
"description": "ProjectOverview: popularity label"
},
+ "d+DOFQ": {
+ "defaultMessage": "Browse posts in {topicName} topic",
+ "description": "TopicPage: posts list heading"
+ },
"dDwm38": {
"defaultMessage": "{website} picture",
"description": "SiteBranding: photo alternative text"
@@ -667,6 +671,10 @@
"defaultMessage": "Settings",
"description": "SiteNavbar: settings modal title in navbar"
},
+ "uUIgCr": {
+ "defaultMessage": "Topics are loading...",
+ "description": "TopicPage: loading topics message"
+ },
"uZj4QI": {
"defaultMessage": "Cancel reply",
"description": "CommentsList: cancel reply button"
@@ -699,10 +707,6 @@
"defaultMessage": "Message:",
"description": "ContactForm: message label"
},
- "zEN3fd": {
- "defaultMessage": "All posts in {topicName}",
- "description": "TopicPage: posts list heading"
- },
"zbzlb1": {
"defaultMessage": "Page {number}",
"description": "BlogPage: page number"
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 9e5754a..9a098fc 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -32,7 +32,7 @@
"description": "MotionToggle: reduce motion label"
},
"/sRqPT": {
- "defaultMessage": "Thématiques liées",
+ "defaultMessage": "Thématiques connexes",
"description": "TopicPage: related thematics list widget title"
},
"/unaGZ": {
@@ -499,6 +499,10 @@
"defaultMessage": "Popularité :",
"description": "ProjectOverview: popularity label"
},
+ "d+DOFQ": {
+ "defaultMessage": "Parcourir les articles au sujet de {topicName}",
+ "description": "TopicPage: posts list heading"
+ },
"dDwm38": {
"defaultMessage": "Photo d’{website}",
"description": "SiteBranding: photo alternative text"
@@ -667,6 +671,10 @@
"defaultMessage": "Réglages",
"description": "SiteNavbar: settings modal title in navbar"
},
+ "uUIgCr": {
+ "defaultMessage": "Les sujets sont en cours de chargement…",
+ "description": "TopicPage: loading topics message"
+ },
"uZj4QI": {
"defaultMessage": "Annuler la réponse",
"description": "CommentsList: cancel reply button"
@@ -699,10 +707,6 @@
"defaultMessage": "Message :",
"description": "ContactForm: message label"
},
- "zEN3fd": {
- "defaultMessage": "Tous les articles dans {topicName}",
- "description": "TopicPage: posts list heading"
- },
"zbzlb1": {
"defaultMessage": "Page {number}",
"description": "BlogPage: page number"
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 185756b..8a9c2f3 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -15,17 +15,24 @@ import {
PageHeader,
PageSidebar,
PageBody,
+ LoadingPage,
+ TocWidget,
+ Spinner,
} from '../../components';
import {
convertWPTopicPreviewToPageLink,
- convertWPTopicToTopic,
fetchAllTopicsSlugs,
fetchTopic,
fetchTopicsCount,
fetchTopicsList,
} from '../../services/graphql';
import styles from '../../styles/pages/blog.module.scss';
-import type { NextPageWithLayout, PageLink, Topic } from '../../types';
+import type {
+ GraphQLConnection,
+ NextPageWithLayout,
+ WPTopic,
+ WPTopicPreview,
+} from '../../types';
import { CONFIG } from '../../utils/config';
import { ROUTES } from '../../utils/constants';
import {
@@ -34,21 +41,45 @@ import {
getSchemaJson,
getSinglePageSchema,
getWebPageSchema,
+ slugify,
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
-import { useBreadcrumb } from '../../utils/hooks';
+import {
+ useBreadcrumb,
+ useHeadingsTree,
+ useTopic,
+ useTopicsList,
+} from '../../utils/hooks';
export type TopicPageProps = {
- currentTopic: Topic;
- topics: PageLink[];
+ data: {
+ currentTopic: WPTopic;
+ otherTopics: GraphQLConnection<WPTopicPreview>;
+ totalTopics: number;
+ };
translation: Messages;
};
-const TopicPage: NextPageWithLayout<TopicPageProps> = ({
- currentTopic,
- topics,
-}) => {
- const { content, intro, meta, slug, title } = currentTopic;
+const TopicPage: NextPageWithLayout<TopicPageProps> = ({ data }) => {
+ const intl = useIntl();
+ const { isFallback } = useRouter();
+ const { isLoading, topic } = useTopic(
+ data.currentTopic.slug,
+ data.currentTopic
+ );
+ const { isLoading: areTopicsLoading, topics } = useTopicsList({
+ fallback: data.otherTopics,
+ input: { first: data.totalTopics, where: { notIn: [topic.id] } },
+ });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+ title: topic.title,
+ url: `${ROUTES.TOPICS}/${topic.slug}`,
+ });
+ const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
+
+ if (isFallback || isLoading) return <LoadingPage />;
+
+ const { content, intro, meta, slug, title } = topic;
const {
articles,
cover,
@@ -57,17 +88,11 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
relatedThematics,
website: officialWebsite,
} = meta;
- const intl = useIntl();
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title,
- url: `${ROUTES.TOPICS}/${slug}`,
- });
- const { asPath } = useRouter();
const webpageSchema = getWebPageSchema({
description: seo.description,
locale: CONFIG.locales.defaultLocale,
- slug: asPath,
+ slug,
title: seo.title,
updateDate: dates.update,
});
@@ -78,30 +103,46 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
id: 'topic',
kind: 'page',
locale: CONFIG.locales.defaultLocale,
- slug: asPath,
+ slug,
title,
});
const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
- const topicsListTitle = intl.formatMessage({
- defaultMessage: 'Other topics',
- description: 'TopicPage: other topics list widget title',
- id: 'JpC3JH',
- });
-
- const thematicsListTitle = intl.formatMessage({
- defaultMessage: 'Related thematics',
- description: 'TopicPage: related thematics list widget title',
- id: '/sRqPT',
- });
+ const messages = {
+ widgets: {
+ loadingTopicsList: intl.formatMessage({
+ defaultMessage: 'Topics are loading...',
+ description: 'TopicPage: loading topics message',
+ id: 'uUIgCr',
+ }),
+ thematicsListTitle: intl.formatMessage({
+ defaultMessage: 'Related thematics',
+ description: 'TopicPage: related thematics list widget title',
+ id: '/sRqPT',
+ }),
+ tocTitle: intl.formatMessage({
+ defaultMessage: 'Table of Contents',
+ description: 'PageLayout: table of contents title',
+ id: 'eys2uX',
+ }),
+ topicsListTitle: intl.formatMessage({
+ defaultMessage: 'Other topics',
+ description: 'TopicPage: other topics list widget title',
+ id: 'JpC3JH',
+ }),
+ },
+ browsePostsTitle: intl.formatMessage(
+ {
+ defaultMessage: 'Browse posts in {topicName} topic',
+ description: 'TopicPage: posts list heading',
+ id: 'd+DOFQ',
+ },
+ { topicName: title }
+ ),
+ };
- const getPageHeading = () => (
- <>
- {cover ? <NextImage {...cover} className={styles['topic-logo']} /> : null}
- {title}
- </>
- );
- const pageUrl = `${CONFIG.url}${asPath}`;
+ const pageUrl = `${CONFIG.url}${slug}`;
+ const browsePostHeadingId = slugify(messages.browsePostsTitle);
return (
<Page breadcrumbs={breadcrumbItems}>
@@ -129,7 +170,14 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
type="application/ld+json"
/>
<PageHeader
- heading={getPageHeading()}
+ heading={
+ <>
+ {cover ? (
+ <NextImage {...cover} className={styles['topic-logo']} />
+ ) : null}
+ {title}
+ </>
+ }
intro={intro}
meta={{
publicationDate: dates.publication,
@@ -138,23 +186,33 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
website: officialWebsite,
}}
/>
- <PageBody className={styles.body}>
- {/*eslint-disable-next-line react/no-danger -- Necessary for content*/}
- {content ? <div dangerouslySetInnerHTML={{ __html: content }} /> : null}
+ <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 {topicName}',
- description: 'TopicPage: posts list heading',
- id: 'zEN3fd',
- },
- { topicName: title }
- )}
+ <Heading id={browsePostHeadingId} level={2}>
+ {messages.browsePostsTitle}
</Heading>
<PostsList
- className={styles.list}
posts={getPostsWithUrl(articles)}
headingLvl={3}
sortByYear
@@ -166,21 +224,25 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
{relatedThematics ? (
<LinksWidget
heading={
- <Heading isFake level={3}>
- {thematicsListTitle}
- </Heading>
+ <Heading level={2}>{messages.widgets.thematicsListTitle}</Heading>
}
items={getLinksItemData(relatedThematics)}
/>
) : null}
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {topicsListTitle}
- </Heading>
- }
- items={getLinksItemData(topics)}
- />
+ {areTopicsLoading ? (
+ <Spinner>{messages.widgets.loadingTopicsList}</Spinner>
+ ) : (
+ <LinksWidget
+ heading={
+ <Heading level={2}>{messages.widgets.topicsListTitle}</Heading>
+ }
+ items={getLinksItemData(
+ topics.edges.map((edge) =>
+ convertWPTopicPreviewToPageLink(edge.node)
+ )
+ )}
+ />
+ )}
</PageSidebar>
</Page>
);
@@ -198,23 +260,19 @@ export const getStaticProps: GetStaticProps<TopicPageProps> = async ({
}) => {
const currentTopic = await fetchTopic((params as TopicParams).slug);
const totalTopics = await fetchTopicsCount();
- const allTopicsEdges = await fetchTopicsList({
+ const otherTopics = await fetchTopicsList({
first: totalTopics,
+ where: { notIn: [currentTopic.databaseId] },
});
- const allTopics = allTopicsEdges.edges.map((edge) =>
- convertWPTopicPreviewToPageLink(edge.node)
- );
- const topicsLinks = allTopics.filter(
- (topic) => topic.url !== `${ROUTES.TOPICS}/${(params as TopicParams).slug}`
- );
const translation = await loadTranslation(locale);
return {
props: {
- currentTopic: JSON.parse(
- JSON.stringify(convertWPTopicToTopic(currentTopic))
- ),
- topics: JSON.parse(JSON.stringify(topicsLinks)),
+ data: {
+ currentTopic: JSON.parse(JSON.stringify(currentTopic)),
+ otherTopics: JSON.parse(JSON.stringify(otherTopics)),
+ totalTopics,
+ },
translation,
},
};
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 f3ed4a9..60e5548 100644
--- a/src/services/graphql/helpers/convert-wp-topic-to-topic.ts
+++ b/src/services/graphql/helpers/convert-wp-topic-to-topic.ts
@@ -3,6 +3,7 @@ import { ROUTES } from '../../../utils/constants';
import {
getUniquePageLinks,
sortPageLinksByName,
+ updateContentTree,
} from '../../../utils/helpers';
import { convertPostPreviewToArticlePreview } from './convert-post-preview-to-article-preview';
import { convertWPThematicPreviewToPageLink } from './convert-taxonomy-to-page-link';
@@ -38,7 +39,7 @@ export const convertWPTopicToTopic = ({
title,
}: WPTopic): Topic => {
return {
- content: contentParts.afterMore,
+ content: updateContentTree(contentParts.afterMore),
id: databaseId,
intro: contentParts.beforeMore,
meta: {
diff --git a/src/styles/pages/blog.module.scss b/src/styles/pages/blog.module.scss
index d1819cd..e8d0034 100644
--- a/src/styles/pages/blog.module.scss
+++ b/src/styles/pages/blog.module.scss
@@ -7,17 +7,6 @@
@use "partials/article-media";
@use "partials/article-wp-blocks";
-.posts-list {
- @include mix.media("screen") {
- @include mix.dimensions("md") {
- --col1: #{fun.convert-px(100)};
- --gap: var(--spacing-lg);
-
- margin-left: calc((var(--col1) + var(--gap)) * -1);
- }
- }
-}
-
.sharing-widget {
@include mix.media("screen") {
@include mix.dimensions("md") {
diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts
index 3fb0ad4..da4ed9e 100644
--- a/src/utils/hooks/index.ts
+++ b/src/utils/hooks/index.ts
@@ -27,3 +27,5 @@ export * from './use-thematics-list';
export * from './use-theme';
export * from './use-timeout';
export * from './use-toggle';
+export * from './use-topic';
+export * from './use-topics-list';
diff --git a/src/utils/hooks/use-topic/index.ts b/src/utils/hooks/use-topic/index.ts
new file mode 100644
index 0000000..e87ab38
--- /dev/null
+++ b/src/utils/hooks/use-topic/index.ts
@@ -0,0 +1 @@
+export * from './use-topic';
diff --git a/src/utils/hooks/use-topic/use-topic.test.ts b/src/utils/hooks/use-topic/use-topic.test.ts
new file mode 100644
index 0000000..e160a3e
--- /dev/null
+++ b/src/utils/hooks/use-topic/use-topic.test.ts
@@ -0,0 +1,54 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ jest,
+} from '@jest/globals';
+import { renderHook, waitFor } from '@testing-library/react';
+import { wpTopicsFixture } from '../../../../tests/fixtures';
+import { ROUTES } from '../../constants';
+import { useTopic } from './use-topic';
+
+describe('useTopic', () => {
+ 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 topic', async () => {
+ const { result } = renderHook(() => useTopic(wpTopicsFixture[0].slug));
+
+ // Inaccurate assertions count because of waitFor...
+ //expect.assertions(8);
+ expect.hasAssertions();
+
+ expect(result.current.topic).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.topic?.slug).toBe(
+ `${ROUTES.TOPICS}/${wpTopicsFixture[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-topic/use-topic.ts b/src/utils/hooks/use-topic/use-topic.ts
new file mode 100644
index 0000000..bd7ee49
--- /dev/null
+++ b/src/utils/hooks/use-topic/use-topic.ts
@@ -0,0 +1,28 @@
+import useSWR from 'swr';
+import { convertWPTopicToTopic, fetchTopic } from '../../../services/graphql';
+import type { Maybe, Topic, WPTopic } from '../../../types';
+
+export type UseTopicReturn<T extends Maybe<WPTopic>> = {
+ isError: boolean;
+ isLoading: boolean;
+ isValidating: boolean;
+ topic: T extends undefined ? Maybe<Topic> : Topic;
+};
+
+export const useTopic = <T extends Maybe<WPTopic>>(
+ slug: string,
+ fallback?: T
+): UseTopicReturn<T> => {
+ const { data, error, isLoading, isValidating } = useSWR(slug, fetchTopic, {
+ fallbackData: fallback,
+ });
+
+ if (error) console.error(error);
+
+ return {
+ isError: !!error,
+ isLoading,
+ isValidating,
+ topic: data ? convertWPTopicToTopic(data) : undefined,
+ } as UseTopicReturn<T>;
+};
diff --git a/src/utils/hooks/use-topics-list/index.ts b/src/utils/hooks/use-topics-list/index.ts
new file mode 100644
index 0000000..c08400f
--- /dev/null
+++ b/src/utils/hooks/use-topics-list/index.ts
@@ -0,0 +1 @@
+export * from './use-topics-list';
diff --git a/src/utils/hooks/use-topics-list/use-topics-list.test.ts b/src/utils/hooks/use-topics-list/use-topics-list.test.ts
new file mode 100644
index 0000000..c8fa607
--- /dev/null
+++ b/src/utils/hooks/use-topics-list/use-topics-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 { useTopicsList } from './use-topics-list';
+
+describe('useTopicsList', () => {
+ 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 topics list', async () => {
+ const { result } = renderHook(() => useTopicsList());
+
+ // Inaccurate assertions count because of waitFor...
+ //expect.assertions(8);
+ expect.hasAssertions();
+
+ expect(result.current.topics).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.topics).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-topics-list/use-topics-list.ts b/src/utils/hooks/use-topics-list/use-topics-list.ts
new file mode 100644
index 0000000..7860486
--- /dev/null
+++ b/src/utils/hooks/use-topics-list/use-topics-list.ts
@@ -0,0 +1,46 @@
+import useSWR from 'swr';
+import {
+ type FetchTopicsListInput,
+ fetchTopicsList,
+} from '../../../services/graphql';
+import type { GraphQLConnection, Maybe, WPTopicPreview } from '../../../types';
+
+export type UseTopicsListReturn<
+ T extends Maybe<GraphQLConnection<WPTopicPreview>>,
+> = {
+ isError: boolean;
+ isLoading: boolean;
+ isValidating: boolean;
+ topics: T extends undefined
+ ? Maybe<GraphQLConnection<WPTopicPreview>>
+ : GraphQLConnection<WPTopicPreview>;
+};
+
+export type UseTopicsListConfig<
+ T extends Maybe<GraphQLConnection<WPTopicPreview>>,
+> = {
+ input?: FetchTopicsListInput;
+ fallback?: T;
+};
+
+export const useTopicsList = <
+ T extends Maybe<GraphQLConnection<WPTopicPreview>>,
+>(
+ config?: UseTopicsListConfig<T>
+): UseTopicsListReturn<T> => {
+ const { fallback, input } = config ?? {};
+ const { data, error, isLoading, isValidating } = useSWR(
+ input ?? {},
+ fetchTopicsList,
+ { fallbackData: fallback }
+ );
+
+ if (error) console.error(error);
+
+ return {
+ isError: !!error,
+ isLoading,
+ isValidating,
+ topics: data,
+ } as UseTopicsListReturn<T>;
+};
diff --git a/tests/cypress/e2e/pages/topic.cy.ts b/tests/cypress/e2e/pages/topic.cy.ts
new file mode 100644
index 0000000..3b30893
--- /dev/null
+++ b/tests/cypress/e2e/pages/topic.cy.ts
@@ -0,0 +1,38 @@
+describe('Topic', () => {
+ beforeEach(() => {
+ cy.visit('/sujet/docker');
+ });
+
+ it('successfully loads', () => {
+ cy.findByRole('heading', { level: 1 }).should('exist');
+ });
+
+ it('contains the topic 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: 'Thématiques connexes' }).should(
+ 'exist'
+ );
+ cy.findByRole('heading', { level: 2, name: 'Autres sujets' }).should(
+ 'exist'
+ );
+ });
+});