aboutsummaryrefslogtreecommitdiffstats
path: root/src/utils
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-12-12 18:50:03 +0100
committerArmand Philippot <git@armandphilippot.com>2023-12-12 18:50:03 +0100
commit85c4c42bd601270d7be0f34a0767a34bb85e29bb (patch)
tree16a07a89cf209139672592fd6988f0c028acb7e9 /src/utils
parent93f87c10783e3d76f1dec667779aedffcae33a39 (diff)
refactor(hooks): rewrite useBreadcrumbs hook
* use next/router to get the slug instead of using props * handle cases where the current page title is not provided * update JSON-LD schema to match the example in documentation * add tests
Diffstat (limited to 'src/utils')
-rw-r--r--src/utils/constants.ts2
-rw-r--r--src/utils/hooks/index.ts2
-rw-r--r--src/utils/hooks/use-breadcrumb.ts124
-rw-r--r--src/utils/hooks/use-breadcrumbs/index.ts1
-rw-r--r--src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx232
-rw-r--r--src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts144
6 files changed, 380 insertions, 125 deletions
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index 9733b15..e968f31 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -26,6 +26,8 @@ export const ROUTES = {
TOPICS: '/sujet',
} as const;
+export const PAGINATED_ROUTE_PREFIX = '/page';
+
// cSpell:ignore legales thematique developpement
export const STORAGE_KEY = {
diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts
index 95cb717..c9ed01e 100644
--- a/src/utils/hooks/index.ts
+++ b/src/utils/hooks/index.ts
@@ -2,7 +2,7 @@ export * from './use-ackee';
export * from './use-article';
export * from './use-articles-list';
export * from './use-boolean';
-export * from './use-breadcrumb';
+export * from './use-breadcrumbs';
export * from './use-comments';
export * from './use-form';
export * from './use-github-repo-meta';
diff --git a/src/utils/hooks/use-breadcrumb.ts b/src/utils/hooks/use-breadcrumb.ts
deleted file mode 100644
index 8b23ff2..0000000
--- a/src/utils/hooks/use-breadcrumb.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/* eslint-disable max-statements */
-import { useIntl } from 'react-intl';
-import type { BreadcrumbList } from 'schema-dts';
-import type { BreadcrumbsItem } from '../../components';
-import { CONFIG } from '../config';
-import { ROUTES } from '../constants';
-import { slugify } from '../helpers';
-
-const isArticle = (url: string) => url.startsWith(`${ROUTES.ARTICLE}/`);
-
-const isHome = (url: string) => url === '/';
-
-const isPageNumber = (url: string) => url.includes('/page/');
-
-const isProject = (url: string) => url.startsWith(`${ROUTES.PROJECTS}/`);
-
-const isSearch = (url: string) => url.startsWith(ROUTES.SEARCH);
-
-const isThematic = (url: string) => url.startsWith(`${ROUTES.THEMATICS}/`);
-
-const isTopic = (url: string) => url.startsWith(`${ROUTES.TOPICS}/`);
-
-const hasBlogAsParent = (url: string) =>
- isArticle(url) ||
- isPageNumber(url) ||
- isSearch(url) ||
- isThematic(url) ||
- isTopic(url);
-
-export type useBreadcrumbProps = {
- /**
- * The current page title.
- */
- title: string;
- /**
- * The current page url.
- */
- url: string;
-};
-
-export type useBreadcrumbReturn = {
- /**
- * The breadcrumb items.
- */
- items: BreadcrumbsItem[];
- /**
- * The breadcrumb JSON schema.
- */
- schema: BreadcrumbList['itemListElement'][];
-};
-
-/**
- * Retrieve the breadcrumb items.
- *
- * @param {useBreadcrumbProps} props - An object (the current page title & url).
- * @returns {useBreadcrumbReturn} The breadcrumb items and its JSON schema.
- */
-export const useBreadcrumb = ({
- title,
- url,
-}: useBreadcrumbProps): useBreadcrumbReturn => {
- const intl = useIntl();
- const labels = {
- home: intl.formatMessage({
- defaultMessage: 'Home',
- description: 'Breadcrumb: home label',
- id: 'j5k9Fe',
- }),
- blog: intl.formatMessage({
- defaultMessage: 'Blog',
- description: 'Breadcrumb: blog label',
- id: 'Es52wh',
- }),
- projects: intl.formatMessage({
- defaultMessage: 'Projects',
- description: 'Breadcrumb: projects label',
- id: '28GZdv',
- }),
- };
-
- const items: BreadcrumbsItem[] = [
- { id: 'home', name: labels.home, url: '/' },
- ];
- const schema: BreadcrumbList['itemListElement'][] = [
- {
- '@type': 'ListItem',
- position: 1,
- name: labels.home,
- item: CONFIG.url,
- },
- ];
-
- if (isHome(url)) return { items, schema };
-
- if (hasBlogAsParent(url)) {
- items.push({ id: 'blog', name: labels.blog, url: ROUTES.BLOG });
- schema.push({
- '@type': 'ListItem',
- position: 2,
- name: labels.blog,
- item: `${CONFIG.url}${ROUTES.BLOG}`,
- });
- }
-
- if (isProject(url)) {
- items.push({ id: 'projects', name: labels.projects, url: ROUTES.PROJECTS });
- schema.push({
- '@type': 'ListItem',
- position: 2,
- name: labels.projects,
- item: `${CONFIG.url}${ROUTES.PROJECTS}`,
- });
- }
-
- items.push({ id: slugify(title), name: title, url });
- schema.push({
- '@type': 'ListItem',
- position: schema.length + 1,
- name: title,
- item: `${CONFIG.url}${url}`,
- });
-
- return { items, schema };
-};
diff --git a/src/utils/hooks/use-breadcrumbs/index.ts b/src/utils/hooks/use-breadcrumbs/index.ts
new file mode 100644
index 0000000..87e5d79
--- /dev/null
+++ b/src/utils/hooks/use-breadcrumbs/index.ts
@@ -0,0 +1 @@
+export * from './use-breadcrumbs';
diff --git a/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx
new file mode 100644
index 0000000..9778aed
--- /dev/null
+++ b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx
@@ -0,0 +1,232 @@
+import { describe, expect, it } from '@jest/globals';
+import { act, renderHook } from '@testing-library/react';
+import nextRouterMock from 'next-router-mock';
+import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider';
+import type { ReactNode } from 'react';
+import { IntlProvider } from 'react-intl';
+import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../constants';
+import { capitalize } from '../../helpers';
+import { useBreadcrumbs } from './use-breadcrumbs';
+
+const AllProviders = ({ children }: { children: ReactNode }) => (
+ <IntlProvider defaultLocale="en" locale="en">
+ <MemoryRouterProvider>{children}</MemoryRouterProvider>
+ </IntlProvider>
+);
+
+describe('useBreadcrumbs', () => {
+ it('returns the breadcrumbs items and its schema', async () => {
+ const currentSlug = '/current-slug';
+ const label = capitalize(
+ (currentSlug.split('/').pop() ?? currentSlug).replaceAll('-', ' ')
+ );
+
+ await act(async () => nextRouterMock.push(currentSlug));
+
+ const { result } = renderHook(() => useBreadcrumbs(), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(4);
+
+ expect(result.current.items).toHaveLength(2);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: currentSlug,
+ label,
+ slug: currentSlug,
+ });
+ expect(result.current.schema).toStrictEqual({
+ '@type': 'BreadcrumbList',
+ '@id': 'breadcrumbs',
+ itemListElement: [
+ {
+ '@type': 'ListItem',
+ item: {
+ '@id': ROUTES.HOME,
+ name: 'Home',
+ },
+ position: 1,
+ },
+ {
+ '@type': 'ListItem',
+ item: {
+ '@id': currentSlug,
+ name: label,
+ },
+ position: 2,
+ },
+ ],
+ });
+ });
+
+ it('can render the items for the 404 page', async () => {
+ await act(async () => nextRouterMock.push(ROUTES.NOT_FOUND));
+
+ const { result } = renderHook(() => useBreadcrumbs(), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(3);
+
+ expect(result.current.items).toHaveLength(2);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: ROUTES.NOT_FOUND,
+ label: '404: Not found',
+ slug: ROUTES.NOT_FOUND,
+ });
+ });
+
+ it('can render the items for the Blog page', async () => {
+ await act(async () => nextRouterMock.push(ROUTES.BLOG));
+
+ const { result } = renderHook(() => useBreadcrumbs(), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(3);
+
+ expect(result.current.items).toHaveLength(2);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: ROUTES.BLOG,
+ label: 'Blog',
+ slug: ROUTES.BLOG,
+ });
+ });
+
+ it('can render the items for the paginated routes', async () => {
+ const pageNumber = 3;
+ const currentSlug = `${ROUTES.BLOG}${PAGINATED_ROUTE_PREFIX}/${pageNumber}`;
+ await act(async () => nextRouterMock.push(currentSlug));
+
+ const { result } = renderHook(() => useBreadcrumbs(), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(4);
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect(result.current.items).toHaveLength(3);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: ROUTES.BLOG,
+ label: 'Blog',
+ slug: ROUTES.BLOG,
+ });
+ expect(result.current.items[2]).toStrictEqual({
+ id: currentSlug,
+ label: `Page ${pageNumber}`,
+ slug: currentSlug,
+ });
+ });
+
+ it('can render the items for the Projects page', async () => {
+ await act(async () => nextRouterMock.push(ROUTES.PROJECTS));
+
+ const { result } = renderHook(() => useBreadcrumbs(), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(3);
+
+ expect(result.current.items).toHaveLength(2);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: ROUTES.PROJECTS,
+ label: 'Projects',
+ slug: ROUTES.PROJECTS,
+ });
+ });
+
+ it('can render the items for the Search page', async () => {
+ const query = 'similique';
+ const currentSlug = `${ROUTES.SEARCH}?s=${query}`;
+ await act(async () => nextRouterMock.push(currentSlug));
+
+ const { result } = renderHook(() => useBreadcrumbs(), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(4);
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect(result.current.items).toHaveLength(3);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: ROUTES.SEARCH,
+ label: 'Search',
+ slug: ROUTES.SEARCH,
+ });
+ expect(result.current.items[2]).toStrictEqual({
+ id: currentSlug,
+ label: `Search results for "${query}"`,
+ slug: currentSlug,
+ });
+ });
+
+ it('can render the items for the Articles page', async () => {
+ const article = {
+ slug: '/the-article-slug',
+ title: 'qui ducimus rerum',
+ };
+ const currentSlug = `${ROUTES.ARTICLE}${article.slug}`;
+ await act(async () => nextRouterMock.push(currentSlug));
+
+ const { result } = renderHook(() => useBreadcrumbs(article.title), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(4);
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect(result.current.items).toHaveLength(3);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: ROUTES.BLOG,
+ label: 'Blog',
+ slug: ROUTES.BLOG,
+ });
+ expect(result.current.items[2]).toStrictEqual({
+ id: currentSlug,
+ label: article.title,
+ slug: currentSlug,
+ });
+ });
+});
diff --git a/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts
new file mode 100644
index 0000000..a0132c0
--- /dev/null
+++ b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts
@@ -0,0 +1,144 @@
+import { useRouter } from 'next/router';
+import { useCallback } from 'react';
+import { useIntl } from 'react-intl';
+import type { BreadcrumbList } from 'schema-dts';
+import type { BreadcrumbsItem } from '../../../components';
+import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../constants';
+import { capitalize } from '../../helpers';
+
+const is404 = (slug: string) => slug === ROUTES.NOT_FOUND;
+const isArticle = (slug: string) => slug === ROUTES.ARTICLE;
+const isBlog = (slug: string) => slug === ROUTES.BLOG;
+const isHome = (slug: string) => slug === ROUTES.HOME;
+const isPaginated = (slug: string) =>
+ new RegExp(`${PAGINATED_ROUTE_PREFIX}/[0-9]+$`).test(slug);
+const isProjects = (slug: string) => slug === ROUTES.PROJECTS;
+const isSearch = (slug: string) => slug.startsWith(ROUTES.SEARCH);
+const isThematic = (slug: string) => slug === ROUTES.THEMATICS;
+const isTopic = (slug: string) => slug === ROUTES.TOPICS;
+
+const getCrumbsSlug = (
+ acc: string[],
+ current: string,
+ index: number
+): string[] => [
+ ...acc,
+ ...(isSearch(`/${current}`) ? [`/${current.split('?s=')[0]}`] : []),
+ `${acc[acc.length - 1]}${index === 0 ? '' : '/'}${current}`,
+];
+
+export type UseBreadcrumbsReturn = {
+ items: BreadcrumbsItem[];
+ schema: BreadcrumbList;
+};
+
+export const useBreadcrumbs = (
+ currentPageTitle?: string
+): UseBreadcrumbsReturn => {
+ const { asPath } = useRouter();
+ const intl = useIntl();
+
+ const getCrumbLabel = useCallback(
+ (slug: string) => {
+ switch (true) {
+ case is404(slug):
+ return intl.formatMessage({
+ defaultMessage: '404: Not found',
+ description: 'UseBreadcrumbs: page not found label',
+ id: 'EH+dam',
+ });
+ case isBlog(slug):
+ return intl.formatMessage({
+ defaultMessage: 'Blog',
+ description: 'UseBreadcrumbs: blog label',
+ id: 'K6aSZi',
+ });
+ case isHome(slug):
+ return intl.formatMessage({
+ defaultMessage: 'Home',
+ description: 'UseBreadcrumbs: home label',
+ id: 'aZIuPO',
+ });
+ case isPaginated(slug):
+ return intl.formatMessage(
+ {
+ defaultMessage: 'Page {number}',
+ description: 'UseBreadcrumbs: paginated route label',
+ id: '/5tytV',
+ },
+ { number: slug.split('/').pop() }
+ );
+ case isProjects(slug):
+ return intl.formatMessage({
+ defaultMessage: 'Projects',
+ description: 'UseBreadcrumbs: projects label',
+ id: 'rkz8C6',
+ });
+ case isSearch(slug):
+ if (slug.includes('?s='))
+ return intl.formatMessage(
+ {
+ defaultMessage: 'Search results for "{query}"',
+ description: 'UseBreadcrumbs: search results label',
+ id: 'gSevGm',
+ },
+ { query: slug.split('?s=').pop() }
+ );
+
+ return intl.formatMessage({
+ defaultMessage: 'Search',
+ description: 'UseBreadcrumbs: search label',
+ id: 'iHC3Qx',
+ });
+ default:
+ return capitalize(
+ (slug.split('/').pop() ?? slug).replaceAll('-', ' ')
+ );
+ }
+ },
+ [intl]
+ );
+
+ const items = asPath
+ .split('/')
+ .filter((part) => part)
+ .reduce(getCrumbsSlug, [ROUTES.HOME as string])
+ .filter((slug) => !slug.endsWith(PAGINATED_ROUTE_PREFIX))
+ .map((slug, index, arr) => {
+ if (isArticle(slug) || isThematic(slug) || isTopic(slug))
+ return {
+ id: ROUTES.BLOG,
+ label: getCrumbLabel(ROUTES.BLOG),
+ slug: ROUTES.BLOG,
+ };
+
+ const isLastSlug = index === arr.length - 1;
+
+ return {
+ id: slug,
+ label:
+ isLastSlug && currentPageTitle
+ ? currentPageTitle
+ : getCrumbLabel(slug),
+ slug,
+ };
+ });
+
+ return {
+ items,
+ schema: {
+ '@type': 'BreadcrumbList',
+ '@id': 'breadcrumbs',
+ itemListElement: items.map((item, index) => {
+ return {
+ '@type': 'ListItem',
+ item: {
+ '@id': item.slug,
+ name: item.label,
+ },
+ position: index + 1,
+ };
+ }),
+ },
+ };
+};