diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-12-12 18:50:03 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-12-12 18:50:03 +0100 |
| commit | 85c4c42bd601270d7be0f34a0767a34bb85e29bb (patch) | |
| tree | 16a07a89cf209139672592fd6988f0c028acb7e9 /src/utils | |
| parent | 93f87c10783e3d76f1dec667779aedffcae33a39 (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.ts | 2 | ||||
| -rw-r--r-- | src/utils/hooks/index.ts | 2 | ||||
| -rw-r--r-- | src/utils/hooks/use-breadcrumb.ts | 124 | ||||
| -rw-r--r-- | src/utils/hooks/use-breadcrumbs/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx | 232 | ||||
| -rw-r--r-- | src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts | 144 |
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, + }; + }), + }, + }; +}; |
