Skip to content

Commit

Permalink
refactor: move the common RHHC Navigation configuration getters to hooks
Browse files Browse the repository at this point in the history
LIIKUNTA-617.
Move the common RHHC Navigation Component getter-configurations
to new hook files, to make the code cleaner and to make it more testable.
  • Loading branch information
nikomakela committed Feb 22, 2024
1 parent b4b750d commit 104fdf5
Show file tree
Hide file tree
Showing 4 changed files with 203 additions and 69 deletions.
77 changes: 8 additions & 69 deletions packages/components/src/components/navigation/Navigation.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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,
});
Expand All @@ -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 (
<>
<RHHCNavigation
Expand All @@ -68,53 +52,8 @@ export default function Navigation({
onTitleClick={() => {
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 */}
<Notification />
Expand Down
Original file line number Diff line number Diff line change
@@ -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) => (
<MemoryRouterProvider>
<CmsHelperProvider
cmsHelper={testCmsHelper}
routerHelper={testRoutedAppHelper}
>
{children}
</CmsHelperProvider>
</MemoryRouterProvider>
),
});
expect(result.current({ path: itemUrl } as MenuItem)).toBeTruthy();
}
);
});
});
Original file line number Diff line number Diff line change
@@ -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)))
);
};
}
Original file line number Diff line number Diff line change
@@ -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
);
};
}

0 comments on commit 104fdf5

Please sign in to comment.