aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-12-01 19:34:58 +0100
committerArmand Philippot <git@armandphilippot.com>2023-12-04 19:00:04 +0100
commit53b63ac27c2275262db9a04be02210a3287aa71d (patch)
tree814968e10cad25e1b34ab251de42ac5ecb82b346
parent11e3ee75fcab0ab54b2bc1713a402c5cc3070c2d (diff)
refactor(pages): refine Blog pages
* replace usePostsList with useArticlesList to keep names coherent * remove useIsMounted hook * rewrite useRedirection hook * add redirect in getStaticProps to avoid unecessary fetching * move Pagination component in a noscript tag * use hooks to refresh thematics and topics lists * complete Cypress tests
-rw-r--r--src/i18n/en.json24
-rw-r--r--src/i18n/fr.json24
-rw-r--r--src/pages/blog/index.tsx305
-rw-r--r--src/pages/blog/page/[number].tsx334
-rw-r--r--src/pages/recherche/index.tsx10
-rw-r--r--src/styles/pages/Page.module.scss44
-rw-r--r--src/styles/pages/blog.module.scss16
-rw-r--r--src/utils/hooks/index.ts3
-rw-r--r--src/utils/hooks/use-articles-list/index.ts1
-rw-r--r--src/utils/hooks/use-articles-list/use-articles-list.test.tsx (renamed from src/utils/hooks/use-posts-list/use-posts-list.test.tsx)51
-rw-r--r--src/utils/hooks/use-articles-list/use-articles-list.ts (renamed from src/utils/hooks/use-posts-list/use-posts-list.ts)21
-rw-r--r--src/utils/hooks/use-is-mounted.tsx17
-rw-r--r--src/utils/hooks/use-pagination/use-pagination.ts8
-rw-r--r--src/utils/hooks/use-posts-list/index.ts1
-rw-r--r--src/utils/hooks/use-redirection.tsx31
-rw-r--r--src/utils/hooks/use-redirection/index.ts1
-rw-r--r--src/utils/hooks/use-redirection/use-redirection.test.ts80
-rw-r--r--src/utils/hooks/use-redirection/use-redirection.ts41
-rw-r--r--tests/cypress/e2e/pages/blog.cy.ts13
-rw-r--r--tests/fixtures/wp-posts.fixture.ts4
20 files changed, 652 insertions, 377 deletions
diff --git a/src/i18n/en.json b/src/i18n/en.json
index f760860..b1768a8 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -147,6 +147,10 @@
"defaultMessage": "{date} at {time}",
"description": "Time: readable date and time"
},
+ "8xVO3Y": {
+ "defaultMessage": "Blog - Page {number}",
+ "description": "BlogPage: page title with number"
+ },
"9MTBCG": {
"defaultMessage": "{thematicsCount, plural, =0 {Thematics:} one {Thematic:} other {Thematics:}}",
"description": "PostPreviewMeta: thematics label"
@@ -343,6 +347,10 @@
"defaultMessage": "Share by Email",
"description": "SharingWidget: Email sharing link"
},
+ "OsclKU": {
+ "defaultMessage": "Topics are loading...",
+ "description": "BlogPage: loading topics message"
+ },
"PBdVsm": {
"defaultMessage": "{starsCount, plural, =0 {No stars} one {# star} other {# stars}}",
"description": "ProjectOverview: stars count"
@@ -459,6 +467,10 @@
"defaultMessage": "Name:",
"description": "CommentForm: name label"
},
+ "ZMES/E": {
+ "defaultMessage": "You can't load more articles without Javascript, please use the pagination instead.",
+ "description": "BlogPage: pagination no script message"
+ },
"ZNBhDP": {
"defaultMessage": "Search results for {query}",
"description": "SearchPage: SEO - Page title"
@@ -507,6 +519,10 @@
"defaultMessage": "{website} picture",
"description": "SiteBranding: photo alternative text"
},
+ "dG3sT3": {
+ "defaultMessage": "Blog: development, open source - Page {number} - {websiteName}",
+ "description": "BlogPage: SEO - Page title"
+ },
"eys2uX": {
"defaultMessage": "Table of Contents",
"description": "PageLayout: table of contents title"
@@ -703,14 +719,14 @@
"defaultMessage": "{minutesCount, plural, =0 {Less than one minute} one {# minute} other {# minutes}}",
"description": "PostPreviewMeta: rounded minutes count"
},
+ "y37FuH": {
+ "defaultMessage": "Thematics are loading...",
+ "description": "BlogPage: loading thematics message"
+ },
"yN5P+m": {
"defaultMessage": "Message:",
"description": "ContactForm: message label"
},
- "zbzlb1": {
- "defaultMessage": "Page {number}",
- "description": "BlogPage: page number"
- },
"zhjPcZ": {
"defaultMessage": "Settings form",
"description": "SiteNavbar: an accessible name for the settings form in navbar"
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 9a098fc..50c9ca7 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -147,6 +147,10 @@
"defaultMessage": "{date} à {time}",
"description": "Time: readable date and time"
},
+ "8xVO3Y": {
+ "defaultMessage": "Blog - Page {number}",
+ "description": "BlogPage: page title with number"
+ },
"9MTBCG": {
"defaultMessage": "{thematicsCount, plural, =0 {Thématiques :} one {Thématique :} other {Thématiques :}}",
"description": "PostPreviewMeta: thematics label"
@@ -343,6 +347,10 @@
"defaultMessage": "Partager par email",
"description": "SharingWidget: Email sharing link"
},
+ "OsclKU": {
+ "defaultMessage": "Les sujets sont en cours de chargement…",
+ "description": "BlogPage: loading topics message"
+ },
"PBdVsm": {
"defaultMessage": "{starsCount, plural, =0 {0 étoile} one {# étoile} other {# étoiles}}",
"description": "ProjectOverview: stars count"
@@ -459,6 +467,10 @@
"defaultMessage": "Nom :",
"description": "CommentForm: name label"
},
+ "ZMES/E": {
+ "defaultMessage": "Vous ne pouvez pas charger plus d’articles sans Javascript, veuillez utiliser la pagination.",
+ "description": "BlogPage: pagination no script message"
+ },
"ZNBhDP": {
"defaultMessage": "Résultats de la recherche pour {query}",
"description": "SearchPage: SEO - Page title"
@@ -507,6 +519,10 @@
"defaultMessage": "Photo d’{website}",
"description": "SiteBranding: photo alternative text"
},
+ "dG3sT3": {
+ "defaultMessage": "Blog: développement, libre et open-source - Page {number} - {websiteName}",
+ "description": "BlogPage: SEO - Page title"
+ },
"eys2uX": {
"defaultMessage": "Table des matières",
"description": "PageLayout: table of contents title"
@@ -703,14 +719,14 @@
"defaultMessage": "{minutesCount, plural, =0 {Moins d’une minute} one {# minute} other {# minutes}}",
"description": "PostPreviewMeta: rounded minutes count"
},
+ "y37FuH": {
+ "defaultMessage": "Les thématiques sont en cours de chargement…",
+ "description": "BlogPage: loading thematics message"
+ },
"yN5P+m": {
"defaultMessage": "Message :",
"description": "ContactForm: message label"
},
- "zbzlb1": {
- "defaultMessage": "Page {number}",
- "description": "BlogPage: page number"
- },
"zhjPcZ": {
"defaultMessage": "Formulaire des réglages",
"description": "SiteNavbar: an accessible name for the settings form in navbar"
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 12bc03e..df25cd2 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,9 +1,8 @@
/* eslint-disable max-statements */
import type { GetStaticProps } from 'next';
import Head from 'next/head';
-import { useRouter } from 'next/router';
import Script from 'next/script';
-import { useCallback, useRef } from 'react';
+import { useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
getLayout,
@@ -18,11 +17,11 @@ import {
PageHeader,
PageBody,
PageSidebar,
+ Spinner,
} from '../../components';
import {
convertWPThematicPreviewToPageLink,
convertWPTopicPreviewToPageLink,
- fetchPostsCount,
fetchPostsList,
fetchThematicsCount,
fetchThematicsList,
@@ -47,71 +46,30 @@ import {
getWebPageSchema,
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
-import { useBreadcrumb, useIsMounted, usePostsList } from '../../utils/hooks';
+import {
+ useArticlesList,
+ useBreadcrumb,
+ useThematicsList,
+ useTopicsList,
+} from '../../utils/hooks';
+
+const renderPaginationLink: RenderPaginationLink = (pageNum) =>
+ `${ROUTES.BLOG}/page/${pageNum}`;
type BlogPageProps = {
- posts: GraphQLConnection<WPPostPreview>;
- thematicsList: WPThematicPreview[];
- topicsList: WPTopicPreview[];
- totalArticles: number;
+ data: {
+ posts: GraphQLConnection<WPPostPreview>;
+ thematics: GraphQLConnection<WPThematicPreview>;
+ topics: GraphQLConnection<WPTopicPreview>;
+ };
translation: Messages;
};
/**
* Blog index page.
*/
-const BlogPage: NextPageWithLayout<BlogPageProps> = ({
- posts,
- thematicsList,
- topicsList,
- totalArticles,
-}) => {
+const BlogPage: NextPageWithLayout<BlogPageProps> = ({ data }) => {
const intl = useIntl();
- const title = intl.formatMessage({
- defaultMessage: 'Blog',
- description: 'BlogPage: page title',
- id: '7TbbIk',
- });
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title,
- url: ROUTES.BLOG,
- });
- const postsListRef = useRef<HTMLDivElement>(null);
- const isMounted = useIsMounted(postsListRef);
- const { asPath } = useRouter();
- const page = {
- title: intl.formatMessage(
- {
- defaultMessage: 'Blog: development, open source - {websiteName}',
- description: 'BlogPage: SEO - Page title',
- id: '+Y+tLK',
- },
- { websiteName: CONFIG.name }
- ),
- url: `${CONFIG.url}${asPath}`,
- };
- const pageDescription = intl.formatMessage(
- {
- defaultMessage:
- "Discover {websiteName}'s writings. He talks about web development, Linux and open source mostly.",
- description: 'BlogPage: SEO - Meta description',
- id: '18h/t0',
- },
- { websiteName: CONFIG.name }
- );
- const webpageSchema = getWebPageSchema({
- description: pageDescription,
- locale: CONFIG.locales.defaultLocale,
- slug: asPath,
- title,
- });
- const blogSchema = getBlogSchema({
- isSinglePage: false,
- locale: CONFIG.locales.defaultLocale,
- slug: asPath,
- });
- const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
-
const {
articles,
error,
@@ -121,27 +79,101 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
isRefreshing,
hasNextPage,
loadMore,
- } = usePostsList({
- fallback: [posts],
- fetcher: fetchPostsList,
+ } = useArticlesList({
+ fallback: [data.posts],
perPage: CONFIG.postsPerPage,
});
+ const { isLoading: areThematicsLoading, thematics } = useThematicsList({
+ fallback: data.thematics,
+ input: { first: data.thematics.pageInfo.total },
+ });
+ const { isLoading: areTopicsLoading, topics } = useTopicsList({
+ fallback: data.topics,
+ input: { first: data.topics.pageInfo.total },
+ });
- const thematicsListTitle = intl.formatMessage({
- defaultMessage: 'Thematics',
- description: 'BlogPage: thematics list widget title',
- id: 'HriY57',
+ const messages = {
+ loading: {
+ thematicsList: intl.formatMessage({
+ defaultMessage: 'Thematics are loading...',
+ description: 'BlogPage: loading thematics message',
+ id: 'y37FuH',
+ }),
+ topicsList: intl.formatMessage({
+ defaultMessage: 'Topics are loading...',
+ description: 'BlogPage: loading topics message',
+ id: 'OsclKU',
+ }),
+ },
+ pageTitle: intl.formatMessage({
+ defaultMessage: 'Blog',
+ description: 'BlogPage: page title',
+ id: '7TbbIk',
+ }),
+ pagination: {
+ noJS: intl.formatMessage({
+ defaultMessage:
+ "You can't load more articles without Javascript, please use the pagination instead.",
+ description: 'BlogPage: pagination no script message',
+ id: 'ZMES/E',
+ }),
+ title: intl.formatMessage({
+ defaultMessage: 'Pagination',
+ description: 'BlogPage: pagination accessible name',
+ id: 'AXe1Iz',
+ }),
+ },
+ seo: {
+ metaDesc: intl.formatMessage(
+ {
+ defaultMessage:
+ "Discover {websiteName}'s writings. He talks about web development, Linux and open source mostly.",
+ description: 'BlogPage: SEO - Meta description',
+ id: '18h/t0',
+ },
+ { websiteName: CONFIG.name }
+ ),
+ title: intl.formatMessage(
+ {
+ defaultMessage: 'Blog: development, open source - {websiteName}',
+ description: 'BlogPage: SEO - Page title',
+ id: '+Y+tLK',
+ },
+ { websiteName: CONFIG.name }
+ ),
+ },
+ widgets: {
+ thematicsListTitle: intl.formatMessage({
+ defaultMessage: 'Thematics',
+ description: 'BlogPage: thematics list widget title',
+ id: 'HriY57',
+ }),
+ topicsListTitle: intl.formatMessage({
+ defaultMessage: 'Topics',
+ description: 'BlogPage: topics list widget title',
+ id: '2D9tB5',
+ }),
+ },
+ };
+
+ const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+ title: messages.pageTitle,
+ url: ROUTES.BLOG,
});
- const topicsListTitle = intl.formatMessage({
- defaultMessage: 'Topics',
- description: 'BlogPage: topics list widget title',
- id: '2D9tB5',
+ const webpageSchema = getWebPageSchema({
+ description: messages.seo.metaDesc,
+ locale: CONFIG.locales.defaultLocale,
+ slug: ROUTES.BLOG,
+ title: messages.pageTitle,
});
- const renderPaginationLink: RenderPaginationLink = useCallback(
- (pageNum) => `${ROUTES.BLOG}/page/${pageNum}`,
- []
- );
+ const blogSchema = getBlogSchema({
+ isSinglePage: false,
+ locale: CONFIG.locales.defaultLocale,
+ slug: ROUTES.BLOG,
+ });
+ const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
+
const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback(
({ kind, pageNumber: number, isCurrentPage }) => {
switch (kind) {
@@ -187,27 +219,19 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
[intl]
);
- const paginationAriaLabel = intl.formatMessage({
- defaultMessage: 'Pagination',
- description: 'BlogPage: pagination accessible name',
- id: 'AXe1Iz',
- });
-
- const blogArticles = articles?.flatMap((p) =>
- p.edges.map((edge) => edge.node)
- );
+ const pageUrl = `${CONFIG.url}${ROUTES.BLOG}`;
return (
<Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
- <title>{page.title}</title>
+ <title>{messages.seo.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
- <meta name="description" content={pageDescription} />
- <meta property="og:url" content={page.url} />
+ <meta name="description" content={messages.seo.metaDesc} />
+ <meta property="og:url" content={pageUrl} />
{/*eslint-disable-next-line react/jsx-no-literals -- Content allowed */}
<meta property="og:type" content="website" />
- <meta property="og:title" content={title} />
- <meta property="og:description" content={pageDescription} />
+ <meta property="og:title" content={messages.pageTitle} />
+ <meta property="og:description" content={messages.seo.metaDesc} />
</Head>
<Script
// eslint-disable-next-line react/jsx-no-literals -- Id allowed
@@ -222,30 +246,24 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
id="schema-breadcrumb"
type="application/ld+json"
/>
- <PageHeader heading={title} meta={{ total: totalArticles }} />
- <PageBody className={styles.body}>
- {blogArticles ? (
+ <PageHeader
+ heading={messages.pageTitle}
+ meta={{ total: data.posts.pageInfo.total }}
+ />
+ <PageBody>
+ {articles ? (
<PostsList
- className={styles.list}
+ className={styles['posts-list']}
firstNewResult={firstNewResultIndex}
isLoading={isLoading || isLoadingMore || isRefreshing}
- onLoadMore={hasNextPage && isMounted ? loadMore : undefined}
- posts={getPostsWithUrl(blogArticles)}
- ref={postsListRef}
+ onLoadMore={hasNextPage ? loadMore : undefined}
+ posts={getPostsWithUrl(
+ articles.flatMap((page) => page.edges.map((edge) => edge.node))
+ )}
sortByYear
- total={isMounted ? totalArticles : undefined}
+ total={data.posts.pageInfo.total}
/>
) : null}
- {isMounted ? null : (
- <Pagination
- aria-label={paginationAriaLabel}
- current={1}
- isCentered
- renderItemAriaLabel={renderPaginationLabel}
- renderLink={renderPaginationLink}
- total={totalArticles}
- />
- )}
{error ? (
<Notice
// eslint-disable-next-line react/jsx-no-literals -- Kind allowed
@@ -258,28 +276,53 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
})}
</Notice>
) : null}
+ <noscript>
+ <Notice
+ // eslint-disable-next-line react/jsx-no-literals
+ kind="info"
+ >
+ {messages.pagination.noJS}
+ </Notice>
+ <Pagination
+ aria-label={messages.pagination.title}
+ className={styles.pagination}
+ current={1}
+ isCentered
+ renderItemAriaLabel={renderPaginationLabel}
+ renderLink={renderPaginationLink}
+ total={data.posts.pageInfo.total}
+ />
+ </noscript>
</PageBody>
<PageSidebar>
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {thematicsListTitle}
- </Heading>
- }
- items={getLinksItemData(
- thematicsList.map(convertWPThematicPreviewToPageLink)
- )}
- />
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {topicsListTitle}
- </Heading>
- }
- items={getLinksItemData(
- topicsList.map(convertWPTopicPreviewToPageLink)
- )}
- />
+ {areThematicsLoading ? (
+ <Spinner>{messages.loading.thematicsList}</Spinner>
+ ) : (
+ <LinksWidget
+ heading={
+ <Heading level={2}>{messages.widgets.thematicsListTitle}</Heading>
+ }
+ items={getLinksItemData(
+ thematics.edges.map((edge) =>
+ convertWPThematicPreviewToPageLink(edge.node)
+ )
+ )}
+ />
+ )}
+ {areTopicsLoading ? (
+ <Spinner>{messages.loading.topicsList}</Spinner>
+ ) : (
+ <LinksWidget
+ heading={
+ <Heading level={2}>{messages.widgets.topicsListTitle}</Heading>
+ }
+ items={getLinksItemData(
+ topics.edges.map((edge) =>
+ convertWPTopicPreviewToPageLink(edge.node)
+ )
+ )}
+ />
+ )}
</PageSidebar>
</Page>
);
@@ -291,7 +334,6 @@ export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
locale,
}) => {
const posts = await fetchPostsList({ first: CONFIG.postsPerPage });
- const totalArticles = await fetchPostsCount();
const totalThematics = await fetchThematicsCount();
const thematics = await fetchThematicsList({ first: totalThematics });
const totalTopics = await fetchTopicsCount();
@@ -300,10 +342,11 @@ export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
return {
props: {
- posts: JSON.parse(JSON.stringify(posts)),
- thematicsList: thematics.edges.map((edge) => edge.node),
- topicsList: topics.edges.map((edge) => edge.node),
- totalArticles,
+ data: {
+ posts: JSON.parse(JSON.stringify(posts)),
+ thematics,
+ topics,
+ },
translation,
},
};
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index 35d4bad..ec465c2 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -18,6 +18,9 @@ import {
PageHeader,
PageBody,
PageSidebar,
+ Spinner,
+ Notice,
+ LoadingPage,
} from '../../../components';
import {
convertWPThematicPreviewToPageLink,
@@ -30,9 +33,12 @@ import {
fetchTopicsCount,
fetchTopicsList,
} from '../../../services/graphql';
+import styles from '../../../styles/pages/blog.module.scss';
import type {
GraphQLConnection,
+ Maybe,
NextPageWithLayout,
+ Nullable,
WPPostPreview,
WPThematicPreview,
WPTopicPreview,
@@ -48,17 +54,24 @@ import {
} from '../../../utils/helpers';
import { loadTranslation, type Messages } from '../../../utils/helpers/server';
import {
+ useArticlesList,
useBreadcrumb,
- usePostsList,
useRedirection,
+ useThematicsList,
+ useTopicsList,
} from '../../../utils/hooks';
+const renderPaginationLink: RenderPaginationLink = (pageNum) =>
+ `${ROUTES.BLOG}/page/${pageNum}`;
+
type BlogPageProps = {
+ data: {
+ posts: GraphQLConnection<WPPostPreview>;
+ thematics: GraphQLConnection<WPThematicPreview>;
+ topics: GraphQLConnection<WPTopicPreview>;
+ };
+ lastCursor: Maybe<Nullable<string>>;
pageNumber: number;
- posts: GraphQLConnection<WPPostPreview>;
- thematicsList: WPThematicPreview[];
- topicsList: WPTopicPreview[];
- totalArticles: number;
translation: Messages;
};
@@ -66,86 +79,129 @@ type BlogPageProps = {
* Blog index page.
*/
const BlogPage: NextPageWithLayout<BlogPageProps> = ({
+ data,
+ lastCursor,
pageNumber,
- posts,
- thematicsList,
- topicsList,
- totalArticles,
}) => {
useRedirection({
- query: { param: 'number', value: '1' },
- redirectTo: ROUTES.BLOG,
+ isReplacing: true,
+ to: ROUTES.BLOG,
+ whenPathMatches: (path) => path === `${ROUTES.BLOG}/page/1`,
});
- const { articles } = usePostsList({
- fallback: [posts],
- fetcher: fetchPostsList,
+ const intl = useIntl();
+ const { isFallback } = useRouter();
+ const {
+ articles,
+ error,
+ firstNewResultIndex,
+ isLoading,
+ isLoadingMore,
+ isRefreshing,
+ hasNextPage,
+ loadMore,
+ } = useArticlesList({
+ after: lastCursor,
+ fallback: [data.posts],
perPage: CONFIG.postsPerPage,
});
- const intl = useIntl();
- const title = intl.formatMessage({
- defaultMessage: 'Blog',
- description: 'BlogPage: page title',
- id: '7TbbIk',
+ const { isLoading: areThematicsLoading, thematics } = useThematicsList({
+ fallback: data.thematics,
+ input: { first: data.thematics.pageInfo.total },
+ });
+ const { isLoading: areTopicsLoading, topics } = useTopicsList({
+ fallback: data.topics,
+ input: { first: data.topics.pageInfo.total },
});
- const pageNumberTitle = intl.formatMessage(
- {
- defaultMessage: 'Page {number}',
- id: 'zbzlb1',
- description: 'BlogPage: page number',
+
+ const messages = {
+ loading: {
+ thematicsList: intl.formatMessage({
+ defaultMessage: 'Thematics are loading...',
+ description: 'BlogPage: loading thematics message',
+ id: 'y37FuH',
+ }),
+ topicsList: intl.formatMessage({
+ defaultMessage: 'Topics are loading...',
+ description: 'BlogPage: loading topics message',
+ id: 'OsclKU',
+ }),
},
- {
- number: pageNumber,
- }
- );
- const pageTitleWithPageNumber = `${title} - ${pageNumberTitle}`;
+ pageTitle: intl.formatMessage(
+ {
+ defaultMessage: 'Blog - Page {number}',
+ description: 'BlogPage: page title with number',
+ id: '8xVO3Y',
+ },
+ {
+ number: pageNumber,
+ }
+ ),
+ pagination: {
+ noJS: intl.formatMessage({
+ defaultMessage:
+ "You can't load more articles without Javascript, please use the pagination instead.",
+ description: 'BlogPage: pagination no script message',
+ id: 'ZMES/E',
+ }),
+ title: intl.formatMessage({
+ defaultMessage: 'Pagination',
+ description: 'BlogPage: pagination accessible name',
+ id: 'AXe1Iz',
+ }),
+ },
+ seo: {
+ metaDesc: intl.formatMessage(
+ {
+ defaultMessage:
+ "Discover {websiteName}'s writings. He talks about web development, Linux and open source mostly.",
+ description: 'BlogPage: SEO - Meta description',
+ id: '18h/t0',
+ },
+ { websiteName: CONFIG.name }
+ ),
+ title: intl.formatMessage(
+ {
+ defaultMessage:
+ 'Blog: development, open source - Page {number} - {websiteName}',
+ description: 'BlogPage: SEO - Page title',
+ id: 'dG3sT3',
+ },
+ { number: pageNumber, websiteName: CONFIG.name }
+ ),
+ },
+ widgets: {
+ thematicsListTitle: intl.formatMessage({
+ defaultMessage: 'Thematics',
+ description: 'BlogPage: thematics list widget title',
+ id: 'HriY57',
+ }),
+ topicsListTitle: intl.formatMessage({
+ defaultMessage: 'Topics',
+ description: 'BlogPage: topics list widget title',
+ id: '2D9tB5',
+ }),
+ },
+ };
+
const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title: pageNumberTitle,
+ title: messages.pageTitle,
url: `${ROUTES.BLOG}/page/${pageNumber}`,
});
- const { asPath } = useRouter();
- const page = {
- title: `${pageTitleWithPageNumber} - ${CONFIG.name}`,
- url: `${CONFIG.url}${asPath}`,
- };
- const pageDescription = intl.formatMessage(
- {
- defaultMessage:
- "Discover {websiteName}'s writings. He talks about web development, Linux and open source mostly.",
- description: 'BlogPage: SEO - Meta description',
- id: '18h/t0',
- },
- { websiteName: CONFIG.name }
- );
const webpageSchema = getWebPageSchema({
- description: pageDescription,
+ description: messages.seo.metaDesc,
locale: CONFIG.locales.defaultLocale,
- slug: asPath,
- title,
+ slug: ROUTES.BLOG,
+ title: messages.pageTitle,
});
const blogSchema = getBlogSchema({
isSinglePage: false,
locale: CONFIG.locales.defaultLocale,
- slug: asPath,
+ slug: ROUTES.BLOG,
});
const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
- const thematicsListTitle = intl.formatMessage({
- defaultMessage: 'Thematics',
- description: 'BlogPage: thematics list widget title',
- id: 'HriY57',
- });
-
- const topicsListTitle = intl.formatMessage({
- defaultMessage: 'Topics',
- description: 'BlogPage: topics list widget title',
- id: '2D9tB5',
- });
- const renderPaginationLink: RenderPaginationLink = useCallback(
- (pageNum) => `${ROUTES.BLOG}/page/${pageNum}`,
- []
- );
const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback(
({ kind, pageNumber: number, isCurrentPage }) => {
switch (kind) {
@@ -191,27 +247,21 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
[intl]
);
- const paginationAriaLabel = intl.formatMessage({
- defaultMessage: 'Pagination',
- description: 'BlogPage: pagination accessible name',
- id: 'AXe1Iz',
- });
+ if (isFallback) return <LoadingPage />;
- const blogPageArticles = articles?.flatMap((p) =>
- p.edges.map((edge) => edge.node)
- );
+ const pageUrl = `${CONFIG.url}${ROUTES.BLOG}`;
return (
<Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
- <title>{page.title}</title>
+ <title>{messages.seo.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
- <meta name="description" content={pageDescription} />
- <meta property="og:url" content={page.url} />
+ <meta name="description" content={messages.seo.metaDesc} />
+ <meta property="og:url" content={pageUrl} />
{/*eslint-disable-next-line react/jsx-no-literals -- Content allowed */}
<meta property="og:type" content="website" />
- <meta property="og:title" content={pageTitleWithPageNumber} />
- <meta property="og:description" content={pageDescription} />
+ <meta property="og:title" content={messages.pageTitle} />
+ <meta property="og:description" content={messages.seo.metaDesc} />
</Head>
<Script
// eslint-disable-next-line react/jsx-no-literals -- Id allowed
@@ -227,41 +277,82 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
type="application/ld+json"
/>
<PageHeader
- heading={pageTitleWithPageNumber}
- meta={{ total: totalArticles }}
+ heading={messages.pageTitle}
+ meta={{ total: data.posts.pageInfo.total }}
/>
<PageBody>
- <PostsList posts={getPostsWithUrl(blogPageArticles ?? [])} sortByYear />
- <Pagination
- aria-label={paginationAriaLabel}
- current={pageNumber}
- isCentered
- renderItemAriaLabel={renderPaginationLabel}
- renderLink={renderPaginationLink}
- total={totalArticles}
- />
+ {articles ? (
+ <PostsList
+ className={styles['posts-list']}
+ firstNewResult={firstNewResultIndex}
+ isLoading={isLoading || isLoadingMore || isRefreshing}
+ onLoadMore={hasNextPage ? loadMore : undefined}
+ posts={getPostsWithUrl(
+ articles.flatMap((page) => page.edges.map((edge) => edge.node))
+ )}
+ sortByYear
+ total={data.posts.pageInfo.total}
+ />
+ ) : null}
+ {error ? (
+ <Notice
+ // eslint-disable-next-line react/jsx-no-literals -- Kind allowed
+ kind="error"
+ >
+ {intl.formatMessage({
+ defaultMessage: 'Failed to load.',
+ description: 'BlogPage: failed to load text',
+ id: 'C/XGkH',
+ })}
+ </Notice>
+ ) : null}
+ <noscript>
+ <Notice
+ // eslint-disable-next-line react/jsx-no-literals
+ kind="info"
+ >
+ {messages.pagination.noJS}
+ </Notice>
+ <Pagination
+ aria-label={messages.pagination.title}
+ className={styles.pagination}
+ current={pageNumber}
+ isCentered
+ renderItemAriaLabel={renderPaginationLabel}
+ renderLink={renderPaginationLink}
+ total={data.posts.pageInfo.total}
+ />
+ </noscript>
</PageBody>
<PageSidebar>
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {thematicsListTitle}
- </Heading>
- }
- items={getLinksItemData(
- thematicsList.map(convertWPThematicPreviewToPageLink)
- )}
- />
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {topicsListTitle}
- </Heading>
- }
- items={getLinksItemData(
- topicsList.map(convertWPTopicPreviewToPageLink)
- )}
- />
+ {areThematicsLoading ? (
+ <Spinner>{messages.loading.thematicsList}</Spinner>
+ ) : (
+ <LinksWidget
+ heading={
+ <Heading level={2}>{messages.widgets.thematicsListTitle}</Heading>
+ }
+ items={getLinksItemData(
+ thematics.edges.map((edge) =>
+ convertWPThematicPreviewToPageLink(edge.node)
+ )
+ )}
+ />
+ )}
+ {areTopicsLoading ? (
+ <Spinner>{messages.loading.topicsList}</Spinner>
+ ) : (
+ <LinksWidget
+ heading={
+ <Heading level={2}>{messages.widgets.topicsListTitle}</Heading>
+ }
+ items={getLinksItemData(
+ topics.edges.map((edge) =>
+ convertWPTopicPreviewToPageLink(edge.node)
+ )
+ )}
+ />
+ )}
</PageSidebar>
</Page>
);
@@ -278,14 +369,23 @@ export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
params,
}) => {
const pageNumber = Number((params as BlogPageParams).number);
- const lastCursor = await fetchLastPostCursor(
- CONFIG.postsPerPage * pageNumber
- );
+
+ if (pageNumber === 1)
+ return {
+ redirect: {
+ destination: ROUTES.BLOG,
+ permanent: true,
+ },
+ };
+
+ const lastCursor =
+ pageNumber > 1
+ ? await fetchLastPostCursor(CONFIG.postsPerPage * (pageNumber - 1))
+ : null;
const posts = await fetchPostsList({
first: CONFIG.postsPerPage,
after: lastCursor,
});
- const totalArticles = await fetchPostsCount();
const totalThematics = await fetchThematicsCount();
const thematics = await fetchThematicsList({ first: totalThematics });
const totalTopics = await fetchTopicsCount();
@@ -294,11 +394,13 @@ export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
return {
props: {
- posts: JSON.parse(JSON.stringify(posts)),
+ data: {
+ posts: JSON.parse(JSON.stringify(posts)),
+ thematics,
+ topics,
+ },
+ lastCursor,
pageNumber,
- thematicsList: thematics.edges.map((edge) => edge.node),
- topicsList: topics.edges.map((edge) => edge.node),
- totalArticles,
translation,
},
};
@@ -317,7 +419,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
return {
paths,
- fallback: false,
+ fallback: true,
};
};
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index bb3aa53..2bcb1c0 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -23,7 +23,6 @@ import {
convertWPThematicPreviewToPageLink,
convertWPTopicPreviewToPageLink,
fetchPostsCount,
- fetchPostsList,
fetchThematicsCount,
fetchThematicsList,
fetchTopicsCount,
@@ -45,7 +44,11 @@ import {
getWebPageSchema,
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
-import { useBreadcrumb, useDataFromAPI, usePostsList } from '../../utils/hooks';
+import {
+ useArticlesList,
+ useBreadcrumb,
+ useDataFromAPI,
+} from '../../utils/hooks';
type SearchPageProps = {
thematicsList: WPThematicPreview[];
@@ -125,9 +128,8 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
isRefreshing,
hasNextPage,
loadMore,
- } = usePostsList({
+ } = useArticlesList({
fallback: [],
- fetcher: fetchPostsList,
perPage: CONFIG.postsPerPage,
searchQuery: query.s as string,
});
diff --git a/src/styles/pages/Page.module.scss b/src/styles/pages/Page.module.scss
deleted file mode 100644
index 5c2848e..0000000
--- a/src/styles/pages/Page.module.scss
+++ /dev/null
@@ -1,44 +0,0 @@
-@use "../abstracts/functions" as fun;
-@use "../abstracts/placeholders";
-
-.article {
- composes: grid from "../layout/_grid.scss";
- align-items: start;
-
- > header {
- grid-column: 1 / -1;
- }
-
- > footer,
- .body {
- grid-column: 2;
- }
-
- &--no-comments {
- margin-bottom: var(--spacing-xl);
- }
-}
-
-.body noscript {
- display: block;
- width: 100%;
- text-align: center;
-}
-
-li.item {
- margin: 0 0 var(--spacing-md) 0;
- border-bottom: fun.convert-px(1) solid var(--color-border);
-}
-
-.comments {
- grid-column: 1 / -1;
- composes: grid from "../layout/_grid.scss";
- margin: var(--spacing-md) 0 0;
- padding: var(--spacing-md) 0 var(--spacing-lg);
- background: var(--color-bg-secondary);
- border-top: fun.convert-px(3) solid var(--color-border-light);
-
- > * {
- grid-column: 2;
- }
-}
diff --git a/src/styles/pages/blog.module.scss b/src/styles/pages/blog.module.scss
index e8d0034..553e9f9 100644
--- a/src/styles/pages/blog.module.scss
+++ b/src/styles/pages/blog.module.scss
@@ -7,6 +7,22 @@
@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-top: var(--spacing-md);
+ margin-left: calc((var(--col1) + var(--gap)) * -1);
+ }
+ }
+}
+
+.pagination {
+ margin-top: var(--spacing-md);
+}
+
.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 da4ed9e..1e0bfe3 100644
--- a/src/utils/hooks/index.ts
+++ b/src/utils/hooks/index.ts
@@ -1,5 +1,6 @@
export * from './use-ackee';
export * from './use-article';
+export * from './use-articles-list';
export * from './use-boolean';
export * from './use-breadcrumb';
export * from './use-comments';
@@ -7,13 +8,11 @@ export * from './use-data-from-api';
export * from './use-form';
export * from './use-github-api';
export * from './use-headings-tree';
-export * from './use-is-mounted';
export * from './use-local-storage';
export * from './use-match-media';
export * from './use-on-click-outside';
export * from './use-on-route-change';
export * from './use-pagination';
-export * from './use-posts-list';
export * from './use-prism';
export * from './use-prism-theme';
export * from './use-redirection';
diff --git a/src/utils/hooks/use-articles-list/index.ts b/src/utils/hooks/use-articles-list/index.ts
new file mode 100644
index 0000000..5f42aeb
--- /dev/null
+++ b/src/utils/hooks/use-articles-list/index.ts
@@ -0,0 +1 @@
+export * from './use-articles-list';
diff --git a/src/utils/hooks/use-posts-list/use-posts-list.test.tsx b/src/utils/hooks/use-articles-list/use-articles-list.test.tsx
index f23ddde..6191ed6 100644
--- a/src/utils/hooks/use-posts-list/use-posts-list.test.tsx
+++ b/src/utils/hooks/use-articles-list/use-articles-list.test.tsx
@@ -6,11 +6,13 @@ import {
it,
jest,
} from '@jest/globals';
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { SWRConfig } from 'swr';
-import { fetchPostsList } from '../../../services/graphql';
-import { usePostsList } from './use-posts-list';
+import { wpPostsFixture } from '../../../../tests/fixtures';
+import { getConnection } from '../../../../tests/utils/graphql';
+import { convertPostPreviewToArticlePreview } from '../../../services/graphql';
+import { useArticlesList } from './use-articles-list';
const wrapper = ({ children }: { children?: ReactNode }) => {
const map = new Map();
@@ -38,7 +40,7 @@ const wrapper = ({ children }: { children?: ReactNode }) => {
);
};
-describe('usePostsList', () => {
+describe('useArticlesList', () => {
beforeEach(() => {
/* Not sure why it is needed, but without it Jest was complaining with `You
* are trying to import a file after the Jest environment has been torn
@@ -55,10 +57,9 @@ describe('usePostsList', () => {
it('can return the first new result index when loading more posts', async () => {
const perPage = 5;
- const { result } = renderHook(
- () => usePostsList({ fetcher: fetchPostsList, perPage }),
- { wrapper }
- );
+ const { result } = renderHook(() => useArticlesList({ perPage }), {
+ wrapper,
+ });
expect.assertions(2);
@@ -71,4 +72,38 @@ describe('usePostsList', () => {
// Assuming there is more than one page.
expect(result.current.firstNewResultIndex).toBe(perPage + 1);
});
+
+ it('converts a WordPress post connection to an article connection', async () => {
+ const perPage = 1;
+ const { result } = renderHook(() => useArticlesList({ perPage }), {
+ wrapper,
+ });
+ const connection = getConnection({
+ after: null,
+ data: wpPostsFixture,
+ first: perPage,
+ });
+
+ expect.hasAssertions();
+
+ await waitFor(() => {
+ expect(result.current.articles).toBeDefined();
+ });
+
+ expect(result.current.articles).toStrictEqual([
+ {
+ edges: connection.edges.map((edge) => {
+ return {
+ cursor: edge.cursor,
+ node: convertPostPreviewToArticlePreview(edge.node),
+ };
+ }),
+ pageInfo: {
+ endCursor: connection.pageInfo.endCursor,
+ hasNextPage: connection.pageInfo.hasNextPage,
+ total: connection.pageInfo.total,
+ },
+ },
+ ]);
+ });
});
diff --git a/src/utils/hooks/use-posts-list/use-posts-list.ts b/src/utils/hooks/use-articles-list/use-articles-list.ts
index bb77f31..8a52702 100644
--- a/src/utils/hooks/use-posts-list/use-posts-list.ts
+++ b/src/utils/hooks/use-articles-list/use-articles-list.ts
@@ -1,4 +1,8 @@
import { useCallback, useState } from 'react';
+import {
+ convertPostPreviewToArticlePreview,
+ fetchPostsList,
+} from '../../../services/graphql';
import type {
ArticlePreview,
GraphQLConnection,
@@ -11,9 +15,8 @@ import {
usePagination,
type UsePaginationReturn,
} from '../use-pagination';
-import { convertPostPreviewToArticlePreview } from 'src/services/graphql';
-export type usePostsListReturn = Omit<
+export type useArticlesListReturn = Omit<
UsePaginationReturn<WPPostPreview>,
'data'
> & {
@@ -27,9 +30,9 @@ export type usePostsListReturn = Omit<
firstNewResultIndex: Maybe<number>;
};
-export const usePostsList = (
- config: UsePaginationConfig<WPPostPreview>
-): usePostsListReturn => {
+export const useArticlesList = (
+ config: Omit<UsePaginationConfig<WPPostPreview>, 'fetcher'>
+): useArticlesListReturn => {
const {
data,
error,
@@ -42,7 +45,7 @@ export const usePostsList = (
isValidating,
loadMore,
size,
- } = usePagination(config);
+ } = usePagination({ ...config, fetcher: fetchPostsList });
const [firstNewResultIndex, setFirstNewResultIndex] =
useState<Maybe<number>>(undefined);
@@ -53,15 +56,15 @@ export const usePostsList = (
}, [config.perPage, loadMore, size]);
const articles: Maybe<GraphQLConnection<ArticlePreview>[]> = data?.map(
- (page): GraphQLConnection<ArticlePreview> => {
+ ({ edges, pageInfo }): GraphQLConnection<ArticlePreview> => {
return {
- edges: page.edges.map((edge): GraphQLEdge<ArticlePreview> => {
+ edges: edges.map((edge): GraphQLEdge<ArticlePreview> => {
return {
cursor: edge.cursor,
node: convertPostPreviewToArticlePreview(edge.node),
};
}),
- pageInfo: page.pageInfo,
+ pageInfo,
};
}
);
diff --git a/src/utils/hooks/use-is-mounted.tsx b/src/utils/hooks/use-is-mounted.tsx
deleted file mode 100644
index 4d85d45..0000000
--- a/src/utils/hooks/use-is-mounted.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { RefObject, useEffect, useState } from 'react';
-
-/**
- * Check if an HTML element is mounted.
- *
- * @param {RefObject<HTMLElement>} ref - A React reference to an HTML element.
- * @returns {boolean} True if the HTML element is mounted.
- */
-export const useIsMounted = (ref: RefObject<HTMLElement>): boolean => {
- const [isMounted, setIsMounted] = useState<boolean>(false);
-
- useEffect(() => {
- if (ref.current) setIsMounted(true);
- }, [ref]);
-
- return isMounted;
-};
diff --git a/src/utils/hooks/use-pagination/use-pagination.ts b/src/utils/hooks/use-pagination/use-pagination.ts
index 2a40aa4..29d5ba2 100644
--- a/src/utils/hooks/use-pagination/use-pagination.ts
+++ b/src/utils/hooks/use-pagination/use-pagination.ts
@@ -11,7 +11,7 @@ export type UsePaginationFetcherInput = GraphQLEdgesInput & {
search?: string;
};
-export type UsePaginationConfig<T> = {
+export type UsePaginationConfig<T> = Pick<GraphQLEdgesInput, 'after'> & {
/**
* The initial data.
*/
@@ -86,6 +86,7 @@ export type UsePaginationReturn<T> = {
* @returns {UsePaginationReturn} An object with pagination data and helpers.
*/
export const usePagination = <T>({
+ after,
fallback,
fetcher,
perPage,
@@ -97,12 +98,11 @@ export const usePagination = <T>({
return {
first: perPage,
- after:
- pageIndex === 0 ? undefined : previousPageData?.pageInfo.endCursor,
+ after: pageIndex === 0 ? after : previousPageData?.pageInfo.endCursor,
search: searchQuery,
};
},
- [perPage, searchQuery]
+ [after, perPage, searchQuery]
);
const { data, error, isLoading, isValidating, setSize, size } =
diff --git a/src/utils/hooks/use-posts-list/index.ts b/src/utils/hooks/use-posts-list/index.ts
deleted file mode 100644
index 664c142..0000000
--- a/src/utils/hooks/use-posts-list/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './use-posts-list';
diff --git a/src/utils/hooks/use-redirection.tsx b/src/utils/hooks/use-redirection.tsx
deleted file mode 100644
index 5a677e2..0000000
--- a/src/utils/hooks/use-redirection.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useRouter } from 'next/router';
-import { useEffect } from 'react';
-
-export type RouterQuery = {
- param: string;
- value: string;
-};
-
-export type UseRedirectionProps = {
- /**
- * The router query.
- */
- query: RouterQuery;
- /**
- * The redirection url.
- */
- redirectTo: string;
-};
-
-/**
- * Redirect to another url when router query match the given parameters.
- *
- * @param {UseRedirectionProps} props - The redirection parameters.
- */
-export const useRedirection = ({ query, redirectTo }: UseRedirectionProps) => {
- const router = useRouter();
-
- useEffect(() => {
- if (router.query[query.param] === query.value) router.push(redirectTo);
- }, [query, redirectTo, router]);
-};
diff --git a/src/utils/hooks/use-redirection/index.ts b/src/utils/hooks/use-redirection/index.ts
new file mode 100644
index 0000000..c81c82c
--- /dev/null
+++ b/src/utils/hooks/use-redirection/index.ts
@@ -0,0 +1 @@
+export * from './use-redirection';
diff --git a/src/utils/hooks/use-redirection/use-redirection.test.ts b/src/utils/hooks/use-redirection/use-redirection.test.ts
new file mode 100644
index 0000000..c14ac4c
--- /dev/null
+++ b/src/utils/hooks/use-redirection/use-redirection.test.ts
@@ -0,0 +1,80 @@
+import { describe, it } from '@jest/globals';
+import { renderHook } from '@testing-library/react';
+import nextRouterMock from 'next-router-mock';
+import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider';
+import { useRedirection } from './use-redirection';
+
+describe('useRedirection', () => {
+ it('redirects to another page', async () => {
+ const initialPath = '/initial-path';
+ const redirectPath = '/redirect-path';
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(2);
+
+ await nextRouterMock.push('/initial-path');
+
+ expect(nextRouterMock.asPath).toBe(initialPath);
+
+ renderHook(() => useRedirection({ to: redirectPath }), {
+ wrapper: MemoryRouterProvider,
+ });
+
+ expect(nextRouterMock.asPath).toBe(redirectPath);
+ });
+
+ it('can replace the url in the history', async () => {
+ const initialPath = '/initial-path';
+ const redirectPath = '/redirect-path';
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(2);
+
+ await nextRouterMock.push('/initial-path');
+
+ expect(nextRouterMock.asPath).toBe(initialPath);
+
+ renderHook(() => useRedirection({ isReplacing: true, to: redirectPath }), {
+ wrapper: MemoryRouterProvider,
+ });
+
+ expect(nextRouterMock.asPath).toBe(redirectPath);
+
+ /* Ideally we should check if when we use `back()` the current path is
+ * still the redirectPath but it is not yet implemented in the mock. */
+ });
+
+ it('can conditionally redirect to another page', async () => {
+ const paths = {
+ initial: '/initial-path',
+ matching: '/matching-path',
+ redirect: '/redirect-path',
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(3);
+
+ await nextRouterMock.push('/initial-path');
+
+ expect(nextRouterMock.asPath).toBe(paths.initial);
+
+ const { rerender } = renderHook(
+ () =>
+ useRedirection({
+ to: paths.redirect,
+ whenPathMatches: (path) => path === paths.matching,
+ }),
+ {
+ wrapper: MemoryRouterProvider,
+ }
+ );
+
+ expect(nextRouterMock.asPath).toBe(paths.initial);
+
+ await nextRouterMock.push(paths.matching);
+
+ rerender();
+
+ expect(nextRouterMock.asPath).toBe(paths.redirect);
+ });
+});
diff --git a/src/utils/hooks/use-redirection/use-redirection.ts b/src/utils/hooks/use-redirection/use-redirection.ts
new file mode 100644
index 0000000..1592a33
--- /dev/null
+++ b/src/utils/hooks/use-redirection/use-redirection.ts
@@ -0,0 +1,41 @@
+import { useRouter } from 'next/router';
+import { useEffect } from 'react';
+
+export type UseRedirectionConfig = {
+ /**
+ * Should the url be replaced in the history?
+ *
+ * @default false
+ */
+ isReplacing?: boolean;
+ /**
+ * The destination.
+ */
+ to: string;
+ /**
+ * Redirect only when the current path matches the condition.
+ *
+ * @param {string} path - The current slug.
+ * @returns {boolean} True if the path matches.
+ */
+ whenPathMatches?: (path: string) => boolean;
+};
+
+export const useRedirection = ({
+ isReplacing = false,
+ to,
+ whenPathMatches,
+}: UseRedirectionConfig) => {
+ const router = useRouter();
+
+ useEffect(() => {
+ const shouldRedirect = whenPathMatches
+ ? whenPathMatches(router.asPath)
+ : true;
+
+ if (shouldRedirect) {
+ if (isReplacing) router.replace(to, undefined, { shallow: true });
+ else router.push(to);
+ }
+ }, [isReplacing, router, to, whenPathMatches]);
+};
diff --git a/tests/cypress/e2e/pages/blog.cy.ts b/tests/cypress/e2e/pages/blog.cy.ts
index 3a422d2..0350e39 100644
--- a/tests/cypress/e2e/pages/blog.cy.ts
+++ b/tests/cypress/e2e/pages/blog.cy.ts
@@ -11,6 +11,14 @@ describe('Blog Page', () => {
cy.visit(ROUTES.BLOG);
});
+ it('successfully loads', () => {
+ cy.findByRole('heading', { level: 1 }).should('exist');
+ });
+
+ it('contains a breadcrumbs', () => {
+ cy.findByRole('navigation', { name: 'Fil d’Ariane' }).should('exist');
+ });
+
it('loads the correct number of pages', () => {
cy.findByText(
/(?<first>\d+) articles chargés sur un total de (?<total>\d+)/i
@@ -49,4 +57,9 @@ describe('Blog Page', () => {
);
});
});
+
+ it('contains a thematics list widget and a topics list widget', () => {
+ cy.findByRole('heading', { level: 2, name: 'Thématiques' }).should('exist');
+ cy.findByRole('heading', { level: 2, name: 'Sujets' }).should('exist');
+ });
});
diff --git a/tests/fixtures/wp-posts.fixture.ts b/tests/fixtures/wp-posts.fixture.ts
index a1b1e4a..7adc928 100644
--- a/tests/fixtures/wp-posts.fixture.ts
+++ b/tests/fixtures/wp-posts.fixture.ts
@@ -1,6 +1,6 @@
import type { WPPost } from '../../src/types';
-export const wpPostsFixture: WPPost[] = [
+export const wpPostsFixture = [
{
acfPosts: null,
author: {
@@ -174,4 +174,4 @@ export const wpPostsFixture: WPPost[] = [
slug: '/post-4',
title: 'Post 4',
},
-];
+] satisfies WPPost[];