From 85c4c42bd601270d7be0f34a0767a34bb85e29bb Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 12 Dec 2023 18:50:03 +0100 Subject: 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 --- src/utils/hooks/use-breadcrumbs/index.ts | 1 + .../hooks/use-breadcrumbs/use-breadcrumbs.test.tsx | 232 +++++++++++++++++++++ src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts | 144 +++++++++++++ 3 files changed, 377 insertions(+) create mode 100644 src/utils/hooks/use-breadcrumbs/index.ts create mode 100644 src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx create mode 100644 src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts (limited to 'src/utils/hooks/use-breadcrumbs') 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 }) => ( + + {children} + +); + +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, + }; + }), + }, + }; +}; -- cgit v1.2.3