diff --git a/packages/components/src/components/navigation/Navigation.tsx b/packages/components/src/components/navigation/Navigation.tsx index 55d922113..9ea19dea3 100644 --- a/packages/components/src/components/navigation/Navigation.tsx +++ b/packages/components/src/components/navigation/Navigation.tsx @@ -1,7 +1,6 @@ -import { resolveHref } from 'next/dist/shared/lib/router/utils/resolve-href'; // NOTE: in Next 14 this is located in: 'next/dist/client/resolve-href' -- https://github.com/vercel/next.js/discussions/22025 import { useRouter } from 'next/router'; -import { useCallback, useContext } from 'react'; +import { useContext } from 'react'; import type { ArticleType, PageType } from 'react-helsinki-headless-cms'; import { Navigation as RHHCNavigation, @@ -11,11 +10,11 @@ import { Notification, useLanguagesQuery, } from 'react-helsinki-headless-cms/apollo'; -import { useCmsHelper, useCmsRoutedAppHelper } from '../../cmsHelperProvider'; -import { useLocale } from '../../hooks'; import { NavigationContext } from '../../navigationProvider'; -import type { AppLanguage, Language, Menu } from '../../types'; +import type { Language, Menu } from '../../types'; import styles from './navigation.module.scss'; +import useGetItemIsActive from './useGetIsItemActive'; +import useGetPathnameForLanguage from './useGetPathnameForLanguage'; type NavigationProps = { page?: PageType | ArticleType; @@ -33,10 +32,8 @@ export default function Navigation({ const { headerMenu, headerUniversalBarMenu, languages } = useContext(NavigationContext); const router = useRouter(); - const { pathname: currentPage, asPath: currentPageAsPath } = router; - const locale = useLocale(); - const cmsHelper = useCmsHelper(); - const routerHelper = useCmsRoutedAppHelper(); + const getIsItemActive = useGetItemIsActive(); + const getPathnameForLanguage = useGetPathnameForLanguage(page); const languagesQuery = useLanguagesQuery({ skip: !!languages || !!forcedLanguages, }); @@ -45,19 +42,6 @@ export default function Navigation({ languages ?? languagesQuery.data?.languages?.filter(isLanguage); - // router.query has no query parameters, even if the current URL does when serving - // server-side generated pages. Simply using window.location.search always when - // available broke e.g. /courses/[eventId] URL part so that the [eventId] part didn't - // get replaced with the actual event ID. Merging both query sources worked better. - const getCurrentParsedUrlQuery = useCallback( - () => ({ - ...router.query, - ...(window - ? Object.fromEntries(new URLSearchParams(window.location.search)) - : {}), - }), - [router.query] - ); return ( <> { router.push('/'); }} - getIsItemActive={({ path }) => { - const pathWithoutTrailingSlash = (path ?? '').replace(/\/$/, ''); - const i18nRouterPathname = routerHelper.getI18nPath( - currentPage, - locale - ); - const i18nRouterAsPath = routerHelper.getI18nPath( - currentPageAsPath, - locale - ); - const [, resolvedUrl] = resolveHref( - router, - { pathname: router.pathname, query: router.query }, - true - ); - const resolvedPathname = resolvedUrl?.split('?')[0]; - return Boolean( - // The router.pathname needs to be checked when dealing with "statically routed page". - pathWithoutTrailingSlash === i18nRouterPathname || - pathWithoutTrailingSlash === `/${locale}${i18nRouterPathname}` || - // The pathname may or may not contain the i18n version of the menu item path - pathWithoutTrailingSlash === resolvedPathname || - pathWithoutTrailingSlash === `/${locale}${resolvedPathname}` || - // Since the menu can contain subitems in a dropdown, the parent items needs to be checked too. - // NOTE: We are now assuming that all the parent items are also real parent pages. - (path && - (i18nRouterAsPath.startsWith(path) || - resolvedPathname?.startsWith(path))) - ); - }} - getPathnameForLanguage={({ slug }) => { - const translatedPage = (page?.translations as PageType[])?.find( - (translation) => translation?.language?.slug === slug - ); - return routerHelper.getLocalizedCmsItemUrl( - currentPage, - translatedPage - ? { - slug: - cmsHelper.getSlugFromUri( - cmsHelper.removeContextPathFromUri(translatedPage.uri) - ) ?? '', - } - : getCurrentParsedUrlQuery(), - slug as AppLanguage - ); - }} + getIsItemActive={getIsItemActive} + getPathnameForLanguage={getPathnameForLanguage} /> {/* CMS notification banner */} diff --git a/packages/components/src/components/navigation/__tests__/useGetIsItemActive.test.tsx b/packages/components/src/components/navigation/__tests__/useGetIsItemActive.test.tsx new file mode 100644 index 000000000..87d46b57e --- /dev/null +++ b/packages/components/src/components/navigation/__tests__/useGetIsItemActive.test.tsx @@ -0,0 +1,110 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import mockRouter from 'next-router-mock'; +import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'; + +import type { MenuItem } from 'react-helsinki-headless-cms'; +import CmsHelperProvider from '../../../cmsHelperProvider/CmsHelperProvider'; +import { APP_LANGUAGES } from '../../../constants'; +import { HeadlessCMSHelper } from '../../../utils/headless-cms/HeadlessCMSHelper'; +import { CmsRoutedAppHelper } from '../../../utils/headless-cms/HeadlessCmsRoutedAppHelper'; +import useGetItemIsActive from '../useGetIsItemActive'; + +const i18nRoutes = { + '/search': [ + { source: '/haku', locale: 'fi' }, + { source: '/sok', locale: 'sv' }, + ], + '/events/:eventId': [ + { source: '/tapahtumat/:eventId', locale: 'fi' }, + { source: '/kurser/:eventId', locale: 'sv' }, + ], + '/articles': [ + { source: '/artikkelit', locale: 'fi' }, + { source: '/artiklar', locale: 'sv' }, + ], + '/articles/:slug*': [ + { source: '/artikkelit/:slug*', locale: 'fi' }, + { source: '/artiklar/:slug*', locale: 'sv' }, + ], + '/pages/:slug*': [ + { source: '/sivut/:slug*', locale: 'fi' }, + { source: '/sidor/:slug*', locale: 'sv' }, + ], +}; + +const testCmsHelper = new HeadlessCMSHelper({ + cmsArticlesContextPath: '/articles', + cmsPagesContextPath: '/pages', + dateFormat: 'dd.MM.yyyy', + ArticleDetails: jest.fn(), +}); + +const testRoutedAppHelper = new CmsRoutedAppHelper({ + i18nRoutes, + locales: APP_LANGUAGES, + URLRewriteMapping: {}, +}); + +type MenuItemLocation = string; +type WindowLocationHref = string; +type MenuItemLocationMatcherType = [MenuItemLocation, WindowLocationHref][]; + +const frontPage: MenuItemLocationMatcherType = [ + ['/', '/'], + ['/fi', '/'], + ['/fi', '/fi'], + ['/sv', '/sv'], + ['/en', '/en'], +]; + +const searchPage: MenuItemLocationMatcherType = [ + ['/fi/search', '/fi/search'], + ['/fi/search', '/fi/haku'], + ['/fi/search', '/haku'], + ['/fi/search', '/haku'], + ['/sv/search', '/sv/search'], + ['/sv/search', '/sv/sok'], + ['/en/search', '/en/search'], + ['/search', '/search'], + ['/search', '/haku'], + ['/fi/search', '/search'], + ['/fi/search', '/haku'], + ['/fi/search', '/search?searchType=Venue'], +]; + +const articlesPage: MenuItemLocationMatcherType = [ + ['/fi/articles', '/fi/artikkelit'], + ['/fi/articles', '/fi/articles'], + ['/sv/articles', '/sv/articles'], + ['/sv/articles', '/sv/artiklar'], + ['/en/articles', '/en/articles'], +]; + +// FIXME: Skipped while no way to mock the nextjs redirects and i18nroutes for router was found. +// eslint-disable-next-line jest/no-disabled-tests +describe.skip('useGetIsItemActive hook for Navigation-component', () => { + describe('getIsItemActive function', () => { + it.each([...frontPage, ...searchPage, ...articlesPage])( + 'returns true for menu item with URL "%s" when current location is "%s"', + async (itemUrl, locationUrl) => { + mockRouter.setCurrentUrl(locationUrl); + await waitFor(() => { + expect(mockRouter.asPath).toStrictEqual(locationUrl); + }); + const { result } = renderHook(() => useGetItemIsActive(), { + wrapper: ({ children }: any) => ( + + + {children} + + + ), + }); + expect(result.current({ path: itemUrl } as MenuItem)).toBeTruthy(); + } + ); + }); +}); diff --git a/packages/components/src/components/navigation/useGetIsItemActive.tsx b/packages/components/src/components/navigation/useGetIsItemActive.tsx new file mode 100644 index 000000000..f67124633 --- /dev/null +++ b/packages/components/src/components/navigation/useGetIsItemActive.tsx @@ -0,0 +1,35 @@ +import { resolveHref } from 'next/dist/shared/lib/router/utils/resolve-href'; // NOTE: in Next 14 this is located in: 'next/dist/client/resolve-href' -- https://github.com/vercel/next.js/discussions/22025 +import { useRouter } from 'next/router'; +import type { NavigationProps } from 'react-helsinki-headless-cms'; +import { useCmsRoutedAppHelper } from '../../cmsHelperProvider'; +import { useLocale } from '../../hooks'; + +export default function useGetItemIsActive(): NonNullable< + NavigationProps['getIsItemActive'] +> { + const router = useRouter(); + const { pathname, asPath, query } = router; + const routerHelper = useCmsRoutedAppHelper(); + const locale = useLocale(); + + return ({ path }) => { + const pathWithoutTrailingSlash = (path ?? '').replace(/\/$/, ''); + const i18nRouterPathname = routerHelper.getI18nPath(pathname, locale); + const i18nRouterAsPath = routerHelper.getI18nPath(asPath, locale); + const [, resolvedUrl] = resolveHref(router, { pathname, query }, true); + const resolvedPathname = resolvedUrl?.split('?')[0]; + return Boolean( + // The router.pathname needs to be checked when dealing with "statically routed page". + pathWithoutTrailingSlash === i18nRouterPathname || + pathWithoutTrailingSlash === `/${locale}${i18nRouterPathname}` || + // The pathname may or may not contain the i18n version of the menu item path + pathWithoutTrailingSlash === resolvedPathname || + pathWithoutTrailingSlash === `/${locale}${resolvedPathname}` || + // Since the menu can contain subitems in a dropdown, the parent items needs to be checked too. + // NOTE: We are now assuming that all the parent items are also real parent pages. + (path && + (i18nRouterAsPath.startsWith(path) || + resolvedPathname?.startsWith(path))) + ); + }; +} diff --git a/packages/components/src/components/navigation/useGetPathnameForLanguage.tsx b/packages/components/src/components/navigation/useGetPathnameForLanguage.tsx new file mode 100644 index 000000000..85b456c4e --- /dev/null +++ b/packages/components/src/components/navigation/useGetPathnameForLanguage.tsx @@ -0,0 +1,50 @@ +import { useRouter } from 'next/router'; +import { useCallback } from 'react'; +import type { + ArticleType, + NavigationProps, + PageType, +} from 'react-helsinki-headless-cms'; +import { useCmsHelper, useCmsRoutedAppHelper } from '../../cmsHelperProvider'; +import type { AppLanguage } from '../../types'; + +export default function useGetPathnameForLanguage( + page?: PageType | ArticleType +): NavigationProps['getPathnameForLanguage'] { + const { pathname: currentPage, query } = useRouter(); + + const cmsHelper = useCmsHelper(); + const routerHelper = useCmsRoutedAppHelper(); + + // router.query has no query parameters, even if the current URL does when serving + // server-side generated pages. Simply using window.location.search always when + // available broke e.g. /courses/[eventId] URL part so that the [eventId] part didn't + // get replaced with the actual event ID. Merging both query sources worked better. + const getCurrentParsedUrlQuery = useCallback( + () => ({ + ...query, + ...(window + ? Object.fromEntries(new URLSearchParams(window.location.search)) + : {}), + }), + [query] + ); + + return ({ slug }) => { + const translatedPage = (page?.translations as PageType[])?.find( + (translation) => translation?.language?.slug === slug + ); + return routerHelper.getLocalizedCmsItemUrl( + currentPage, + translatedPage + ? { + slug: + cmsHelper.getSlugFromUri( + cmsHelper.removeContextPathFromUri(translatedPage.uri) + ) ?? '', + } + : getCurrentParsedUrlQuery(), + slug as AppLanguage + ); + }; +}