diff --git a/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx b/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx index 866c17ccbd5..7527e956bdf 100644 --- a/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx +++ b/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx @@ -1,9 +1,8 @@ -import { useCallback } from 'react'; +import { useMemo } from 'react'; import { Breadcrumbs as MuiBreadcrumbs } from '@mui/material'; import LoadingEllipsis from 'lib/components/core/LoadingEllipsis'; -import { CrumbData } from 'lib/hooks/router/dynamicNest'; -import { CrumbContent } from 'lib/hooks/router/dynamicNest/crumbs'; +import { CrumbData, forEachFlatCrumb } from 'lib/hooks/router/dynamicNest'; import useTranslation, { translatable } from 'lib/hooks/useTranslation'; import Crumb from './Crumb'; @@ -22,34 +21,19 @@ const Breadcrumbs = (props: BreadcrumbProps): JSX.Element => { const sliders = useSliders(); - const getCrumbElement = useCallback( - (content: CrumbContent, disabled: boolean, key: string): JSX.Element => ( - - {translatable(content.title) ? t(content.title) : content.title} - - ), - [], - ); - - const validCrumbs = crumbs.reduce((elements, crumb, index) => { - const content = crumb.content; - if (!content) return elements; - - const isLastCrumb = index >= crumbs.length - 1; - - if (Array.isArray(content)) { - content.forEach((item, itemIndex) => { - const isLastItem = isLastCrumb && itemIndex >= content.length - 1; - const key = `${crumb.pathname}-${itemIndex}`; + const validCrumbs = useMemo(() => { + const elements: JSX.Element[] = []; - elements.push(getCrumbElement(item, isLastItem, key)); - }); - } else { - elements.push(getCrumbElement(content, isLastCrumb, crumb.pathname)); - } + forEachFlatCrumb(crumbs, (content, isLastCrumb, key) => { + elements.push( + + {translatable(content.title) ? t(content.title) : content.title} + , + ); + }); return elements; - }, []); + }, [crumbs]); return (
diff --git a/client/app/bundles/course/container/CourseContainer.tsx b/client/app/bundles/course/container/CourseContainer.tsx index 9ad3a4d7fed..03c31b05368 100644 --- a/client/app/bundles/course/container/CourseContainer.tsx +++ b/client/app/bundles/course/container/CourseContainer.tsx @@ -7,9 +7,12 @@ import { CourseLayoutData } from 'types/course/courses'; import CikgoSidebarItems from 'course/stories/components/CikgoSidebarItems'; import PopupNotifier from 'course/user-notification/PopupNotifier'; import Footer from 'lib/components/core/layouts/Footer'; -import { DataHandle, useDynamicNest } from 'lib/hooks/router/dynamicNest'; -import { DEFAULT_WINDOW_TITLE } from 'lib/hooks/router/dynamicNest/constants'; -import { getLastCrumbTitle } from 'lib/hooks/router/dynamicNest/crumbs'; +import { + DataHandle, + DEFAULT_WINDOW_TITLE, + getLastCrumbTitle, + useDynamicNest, +} from 'lib/hooks/router/dynamicNest'; import useTranslation, { translatable } from 'lib/hooks/useTranslation'; import Breadcrumbs from './Breadcrumbs'; diff --git a/client/app/lib/containers/CourselessContainer.tsx b/client/app/lib/containers/CourselessContainer.tsx index 8e6bd9d7df7..5ea94ca6b55 100644 --- a/client/app/lib/containers/CourselessContainer.tsx +++ b/client/app/lib/containers/CourselessContainer.tsx @@ -2,9 +2,11 @@ import { useEffect } from 'react'; import { Outlet } from 'react-router-dom'; import Footer from 'lib/components/core/layouts/Footer'; -import { useDynamicNest } from 'lib/hooks/router/dynamicNest'; -import { DEFAULT_WINDOW_TITLE } from 'lib/hooks/router/dynamicNest/constants'; -import { getLastCrumbTitle } from 'lib/hooks/router/dynamicNest/crumbs'; +import { + DEFAULT_WINDOW_TITLE, + getLastCrumbTitle, + useDynamicNest, +} from 'lib/hooks/router/dynamicNest'; import useTranslation, { translatable } from 'lib/hooks/useTranslation'; import BrandingHead from '../components/navigation/BrandingHead'; diff --git a/client/app/lib/hooks/router/dynamicNest.ts b/client/app/lib/hooks/router/dynamicNest.ts new file mode 100644 index 00000000000..8960b14a100 --- /dev/null +++ b/client/app/lib/hooks/router/dynamicNest.ts @@ -0,0 +1,39 @@ +import { + type CrumbContent as RDBCrumbContent, + type CrumbData as RDBCrumbData, + type CrumbPath as RDBCrumbPath, + type DataHandle as RDBDataHandle, + forEachFlatCrumb, + getLastCrumbTitle, + type Match as RDBMatch, + useDynamicBreadcrumbs, +} from 'react-dynamic-breadcrumbs'; +import { Location, useLocation, useMatches } from 'react-router-dom'; + +import type { Descriptor } from '../useTranslation'; + +type CrumbTitle = string | Descriptor | null | undefined; + +export type CrumbData = RDBCrumbData; +export type CrumbContent = RDBCrumbContent; +export type CrumbPath = RDBCrumbPath; +export type DataHandle = RDBDataHandle< + Location, + Omit[number], 'handle'>, + CrumbTitle +>; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useDynamicNest = () => { + const matches = useMatches() as RDBMatch[]; + const location = useLocation(); + + return useDynamicBreadcrumbs({ + matches, + context: location, + }); +}; + +export const DEFAULT_WINDOW_TITLE = 'Coursemology'; + +export { forEachFlatCrumb, getLastCrumbTitle }; diff --git a/client/app/lib/hooks/router/dynamicNest/builder.ts b/client/app/lib/hooks/router/dynamicNest/builder.ts deleted file mode 100644 index 0ed5465acf0..00000000000 --- a/client/app/lib/hooks/router/dynamicNest/builder.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Location } from 'react-router-dom'; - -import { buildCrumbData, CrumbData, CrumbState } from './crumbs'; -import { - getHandleData, - getHandleRequestData, - getShouldRevalidateCrumb, -} from './handles'; -import { isPromise, Match, Promisable } from './utils'; - -interface CrumbsDataBuilderResult { - data: Promisable; - hasPending: boolean; - invalidCrumbs: Set; -} - -export const buildCrumbsData = ( - matches: Match[], - location: Location, - state: CrumbState, -): CrumbsDataBuilderResult => { - const invalidCrumbs = new Set(Object.keys(state)); - const oldCrumbsCount = invalidCrumbs.size; - - let hasPromise = false; - let newCrumbsCount = 0; - - const delta = matches.reduce[]>((crumbs, match) => { - const handleData = getHandleData(match, location); - if (!handleData) return crumbs; - - newCrumbsCount += 1; - - const crumbExists = invalidCrumbs.has(match.pathname); - - if (getShouldRevalidateCrumb(handleData) || crumbExists) - invalidCrumbs.delete(match.pathname); - - if (getShouldRevalidateCrumb(handleData) || !crumbExists) { - const content = getHandleRequestData(handleData); - - if (isPromise(content)) { - hasPromise = true; - crumbs.push(content.then((data) => buildCrumbData(match, data))); - } else { - crumbs.push(buildCrumbData(match, content)); - } - } - - return crumbs; - }, []); - - const validCrumbsCount = oldCrumbsCount - invalidCrumbs.size; - - return { - data: hasPromise ? Promise.all(delta) : (delta as CrumbData[]), - hasPending: newCrumbsCount > validCrumbsCount, - invalidCrumbs, - }; -}; diff --git a/client/app/lib/hooks/router/dynamicNest/constants.ts b/client/app/lib/hooks/router/dynamicNest/constants.ts deleted file mode 100644 index 8de2c9806d0..00000000000 --- a/client/app/lib/hooks/router/dynamicNest/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const DEFAULT_WINDOW_TITLE = 'Coursemology'; diff --git a/client/app/lib/hooks/router/dynamicNest/crumbs.ts b/client/app/lib/hooks/router/dynamicNest/crumbs.ts deleted file mode 100644 index 3a7a705d231..00000000000 --- a/client/app/lib/hooks/router/dynamicNest/crumbs.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { produce } from 'immer'; - -import { Descriptor } from 'lib/hooks/useTranslation'; - -import { isRecord, Match } from './utils'; - -export type CrumbTitle = string | Descriptor | null | undefined; - -export interface CrumbContent { - url?: string; - title?: CrumbTitle; -} - -export interface CrumbData { - id: string; - pathname: string; - activePath?: string | null; - content?: CrumbContent | CrumbContent[]; -} - -export interface CrumbPath { - pathname?: string; - activePath?: string | null; - content?: CrumbContent | CrumbContent[]; -} - -export type CrumbState = Record; - -export const getLastCrumbTitle = (crumbs: CrumbData[]): CrumbTitle | null => { - const content = crumbs[crumbs.length - 1]?.content; - if (!content) return null; - - const actualContent = Array.isArray(content) - ? content[content.length - 1] - : content; - if (!actualContent) return null; - - return actualContent.title; -}; - -export const isCrumbPath = (value: unknown): value is CrumbPath => - isRecord(value) && 'content' in value; - -export const buildCrumbData = ( - match: Match, - data: CrumbTitle | CrumbPath, -): CrumbData => ({ - id: match.id, - pathname: match.pathname, - ...(isCrumbPath(data) - ? data - : { - content: { - title: data, - url: match.pathname, - }, - }), -}); - -export const combineCrumbs = ( - delta: CrumbData[], -): ((state: CrumbState) => CrumbState) => - produce((draft) => { - delta.forEach((crumb) => { - draft[crumb.pathname] = crumb; - }); - }); diff --git a/client/app/lib/hooks/router/dynamicNest/handles.ts b/client/app/lib/hooks/router/dynamicNest/handles.ts deleted file mode 100644 index 9b258f5d2eb..00000000000 --- a/client/app/lib/hooks/router/dynamicNest/handles.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Location } from 'react-router-dom'; - -import { CrumbPath, CrumbTitle } from './crumbs'; -import { isRecord, Match, Promisable } from './utils'; - -interface HandleRequest { - shouldRevalidate?: boolean; - getData: () => Promisable; -} - -export type HandleData = CrumbTitle | HandleRequest | null; - -export type DataHandle = (match: Match, location: Location) => HandleData; - -export type Handle = CrumbTitle | DataHandle; - -export const isHandleRequest = (value: unknown): value is HandleRequest => - isRecord(value) && 'getData' in value && typeof value.getData === 'function'; - -export const getHandleData = (match: Match, location: Location): HandleData => { - const handle = match.handle as Handle | undefined; - if (!handle) return null; - - return typeof handle === 'function' ? handle(match, location) : handle; -}; - -export const getShouldRevalidateCrumb = (data: HandleData): boolean => - isHandleRequest(data) && (data.shouldRevalidate ?? false); - -export const getHandleRequestData = ( - data: HandleData, -): Promisable => - isHandleRequest(data) ? data.getData() : data; diff --git a/client/app/lib/hooks/router/dynamicNest/index.ts b/client/app/lib/hooks/router/dynamicNest/index.ts deleted file mode 100644 index 1cfad6a63e7..00000000000 --- a/client/app/lib/hooks/router/dynamicNest/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type { CrumbContent, CrumbData, CrumbPath, CrumbTitle } from './crumbs'; -export type { DataHandle } from './handles'; -export { default as useDynamicNest } from './useDynamicNest'; diff --git a/client/app/lib/hooks/router/dynamicNest/useDynamicNest.ts b/client/app/lib/hooks/router/dynamicNest/useDynamicNest.ts deleted file mode 100644 index fee4afba24b..00000000000 --- a/client/app/lib/hooks/router/dynamicNest/useDynamicNest.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { useLocation, useMatches } from 'react-router-dom'; -import { produce } from 'immer'; - -import { buildCrumbsData } from './builder'; -import { combineCrumbs, CrumbData, CrumbState } from './crumbs'; -import { isPromise } from './utils'; - -interface UseDynamicNestHook { - crumbs: CrumbData[]; - loading: boolean; - activePath?: string; -} - -const useDynamicNest = (): UseDynamicNestHook => { - const matches = useMatches(); - const location = useLocation(); - - const [state, setState] = useState({}); - const [loading, setLoading] = useState(false); - - useEffect(() => { - let ignore = false; - - const result = buildCrumbsData(matches, location, state); - - if (isPromise(result.data)) { - setLoading(result.hasPending); - - result.data - .then((crumbs) => !ignore && setState(combineCrumbs(crumbs))) - .finally(() => setLoading(false)); - } else { - setState(combineCrumbs(result.data)); - } - - setState( - produce((draft) => { - result.invalidCrumbs.forEach((pathname) => delete draft[pathname]); - }), - ); - - return () => { - ignore = true; - }; - }, [matches]); - - const crumbs = useMemo( - () => Object.values(state).sort((a, b) => a.id.localeCompare(b.id)), - [state], - ); - - const activePath = useMemo(() => { - for (let index = crumbs.length - 1; index >= 0; index--) { - const crumb = crumbs[index]; - - // Allow the distinction between `undefined` and `null`. If `activePath` - // is `null`, it means that explicitly there shouldn't be any active paths - // for the current nest. - if (crumb.activePath !== undefined) return crumb.activePath ?? ''; - } - - return undefined; - }, [crumbs]); - - return { crumbs, loading, activePath }; -}; - -export default useDynamicNest; diff --git a/client/app/lib/hooks/router/dynamicNest/utils.ts b/client/app/lib/hooks/router/dynamicNest/utils.ts deleted file mode 100644 index 2eddfe7e95b..00000000000 --- a/client/app/lib/hooks/router/dynamicNest/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useMatches } from 'react-router-dom'; - -export type Promisable = T | Promise; - -export type Match = ReturnType[number]; - -export const isRecord = ( - value: unknown, -): value is Record => typeof value === 'object' && value !== null; - -export const isPromise = (value: unknown): value is Promise => - isRecord(value) && 'then' in value && typeof value.then === 'function'; diff --git a/client/package.json b/client/package.json index 24d7242181e..da4c7900aa6 100644 --- a/client/package.json +++ b/client/package.json @@ -80,6 +80,7 @@ "react-dom": "^18.2.0", "react-draggable": "^4.4.6", "react-dropzone": "^14.2.3", + "react-dynamic-breadcrumbs": "^1.0.1", "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.51.1", "react-hot-keys": "^2.7.3", diff --git a/client/yarn.lock b/client/yarn.lock index 1bdae2a126b..1da56906603 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -6267,6 +6267,11 @@ immer@^10.0.4: resolved "https://registry.yarnpkg.com/immer/-/immer-10.0.4.tgz#09af41477236b99449f9d705369a4daaf780362b" integrity sha512-cuBuGK40P/sk5IzWa9QPUaAdvPHjkk1c+xYsd9oZw+YQQEV+10G0P5uMpGctZZKnyQ+ibRO08bD25nWLmYi2pw== +immer@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" + integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== + immer@^9.0.21: version "9.0.21" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176" @@ -9296,6 +9301,13 @@ react-dropzone@^14.2.3: file-selector "^0.6.0" prop-types "^15.8.1" +react-dynamic-breadcrumbs@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/react-dynamic-breadcrumbs/-/react-dynamic-breadcrumbs-1.0.1.tgz#6dd4b7f494a55baab4d51d2ff51aab733c5d5305" + integrity sha512-WQY6rcjtMHQUxBL+XKSVq4ZohDA0BcOJgahStjBeW7RZS1StAg3Q5Xz+zKo5lUQ+46KJWTcmaaMtFW0UnjICNg== + dependencies: + immer "^10.1.1" + react-fast-compare@^3.0.1: version "3.2.0" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"