diff --git a/lms/static/scripts/frontend_apps/components/ComponentWithLoaderWrapper.tsx b/lms/static/scripts/frontend_apps/components/ComponentWithLoaderWrapper.tsx index a90c86749b..fcd8f61b6b 100644 --- a/lms/static/scripts/frontend_apps/components/ComponentWithLoaderWrapper.tsx +++ b/lms/static/scripts/frontend_apps/components/ComponentWithLoaderWrapper.tsx @@ -1,7 +1,8 @@ import { Card, CardContent } from '@hypothesis/frontend-shared'; -import type { ComponentChildren } from 'preact'; -import { useEffect, useState } from 'preact/hooks'; -import { useParams, useRoute } from 'wouter-preact'; +import type { ComponentChildren, FunctionalComponent } from 'preact'; +import { useEffect, useMemo, useState } from 'preact/hooks'; +import type { Parser } from 'wouter-preact'; +import { useLocation, useParams, useRouter } from 'wouter-preact'; import type { ConfigObject, Ensure } from '../config'; import { useConfig } from '../config'; @@ -14,40 +15,93 @@ export type LoaderOptions = { signal: AbortSignal; }; +type RouteModule = { + loader: (opts: LoaderOptions) => unknown; + default: FunctionalComponent; +}; + +const matchRoute = (parser: Parser, route: string, path: string) => { + const { pattern, keys } = parser(route); + + // array destructuring loses keys, so this is done in two steps + const result = pattern.exec(path) || []; + + // when parser is in "loose" mode, `$base` is equal to the + // first part of the route that matches the pattern + // (e.g. for pattern `/a/:b` and path `/a/1/2/3` the `$base` is `a/1`) + // we use this for route nesting + const [$base, ...matches] = result; + + if ($base === undefined) { + return [false, null]; + } + + // an object with parameters matched, e.g. { foo: "bar" } for "/:foo" + // we "zip" two arrays here to construct the object + // ["foo"], ["bar"] → { foo: "bar" } + const groups = Object.fromEntries(keys.map((key, i) => [key, matches[i]])); + + // convert the array to an instance of object + // this makes it easier to integrate with the existing param implementation + const obj = { ...matches }; + // merge named capture groups with matches array + Object.assign(obj, groups); + + return [true, obj]; +}; + +function useRouteModule( + routeToModuleMap: Map Promise>, +) { + const { parser } = useRouter(); + const [location] = useLocation(); + + return useMemo(() => { + for (const [route, moduleResolver] of routeToModuleMap) { + const [matches, params] = matchRoute(parser, route, location); + if (matches) { + return { module: moduleResolver(), params: params ?? {} }; + } + } + + return undefined; + }, [location, parser, routeToModuleMap]); +} + +const routesMap = new Map([ + [ + '/assignments/:assignmentId', + () => import('./dashboard/AssignmentActivity') as Promise, + ], + [ + '/courses/:courseId', + () => import('./dashboard/CourseActivity') as Promise, + ], + [ + '', + () => import('./dashboard/OrganizationActivity') as Promise, + ], +]); + export default function ComponentWithLoaderWrapper() { const config = useConfig(['dashboard', 'api']); const [component, setComponent] = useState(); const [loading, setLoading] = useState(true); const [fatalError, setFatalError] = useState(); - - const [isAssignment, assignmentParams] = useRoute( - '/assignments/:assignmentId', - ); - const [isCourse, courseParams] = useRoute('/courses/:courseId'); - const [isHome] = useRoute(''); - const globalParams = useParams(); - const assignmentId = assignmentParams?.assignmentId ?? ''; - const courseId = courseParams?.courseId ?? ''; - const organizationId = globalParams.organizationId ?? ''; + const { organizationId } = useParams<{ organizationId: string }>(); + const routeModule = useRouteModule(routesMap); useEffect(() => { - const loaderModule = isAssignment - ? import('./dashboard/AssignmentActivity') - : isCourse - ? import('./dashboard/CourseActivity') - : import('./dashboard/OrganizationActivity'); - const params = { assignmentId, courseId, organizationId }; - const abortController = new AbortController(); - loaderModule.then(async ({ loader, default: Component }) => { + routeModule?.module.then(async ({ loader, default: Component }) => { setLoading(true); try { const loaderResult = await loader({ config, - params, + params: { ...routeModule.params, organizationId }, signal: abortController.signal, }); - setComponent(); + setComponent(); } catch (e) { setFatalError(e); } finally { @@ -56,7 +110,7 @@ export default function ComponentWithLoaderWrapper() { }); return () => abortController.abort(); - }, [assignmentId, courseId, organizationId, config, isAssignment, isCourse]); + }, [config, organizationId, routeModule]); if (fatalError) { return (