From e5d814dfb19a130a8ffb78cb96cd2d251c410f3c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 13 Jun 2024 14:46:55 +0200 Subject: [PATCH] Add route-to-module map to dashboard --- .../components/ComponentWithLoaderWrapper.tsx | 146 +++++++++++++----- .../dashboard/AssignmentActivity.tsx | 3 - 2 files changed, 111 insertions(+), 38 deletions(-) diff --git a/lms/static/scripts/frontend_apps/components/ComponentWithLoaderWrapper.tsx b/lms/static/scripts/frontend_apps/components/ComponentWithLoaderWrapper.tsx index a90c86749b..17cdf16a1c 100644 --- a/lms/static/scripts/frontend_apps/components/ComponentWithLoaderWrapper.tsx +++ b/lms/static/scripts/frontend_apps/components/ComponentWithLoaderWrapper.tsx @@ -1,12 +1,22 @@ 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'; import type { ErrorLike } from '../errors'; import ErrorDisplay from './ErrorDisplay'; +import AssignmentActivity, { + loader as assignmentLoader, +} from './dashboard/AssignmentActivity'; +import CourseActivity, { + loader as courseLoader, +} from './dashboard/CourseActivity'; +import OrganizationActivity, { + loader as organizationLoader, +} from './dashboard/OrganizationActivity'; export type LoaderOptions = { config: Ensure; @@ -14,49 +24,115 @@ export type LoaderOptions = { signal: AbortSignal; }; +type RouteModule = { + loader: (opts: LoaderOptions) => Promise; + Component: 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 RouteModule>) { + 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 RouteModule>([ + [ + '/assignments/:assignmentId', + () => + ({ + loader: assignmentLoader, + Component: AssignmentActivity, + }) as RouteModule, + ], + [ + '/courses/:courseId', + () => + ({ + loader: courseLoader, + Component: CourseActivity, + }) as RouteModule, + ], + [ + '', + () => + ({ + loader: organizationLoader, + Component: OrganizationActivity, + }) as RouteModule, + ], +]); + 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 }; + if (!routeModule) { + return () => {}; + } const abortController = new AbortController(); - loaderModule.then(async ({ loader, default: Component }) => { - setLoading(true); - try { - const loaderResult = await loader({ - config, - params, - signal: abortController.signal, - }); - setComponent(); - } catch (e) { - setFatalError(e); - } finally { - setLoading(false); - } - }); + const { module, params } = routeModule; + const { loader, Component } = module; + + setLoading(true); + loader({ + config, + params: { ...params, organizationId }, + signal: abortController.signal, + }) + .then(loaderResult => + setComponent(), + ) + .catch(e => setFatalError(e)) + .finally(() => setLoading(false)); return () => abortController.abort(); - }, [assignmentId, courseId, organizationId, config, isAssignment, isCourse]); + }, [config, organizationId, routeModule]); if (fatalError) { return ( diff --git a/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx b/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx index 56679fd4d0..db05499193 100644 --- a/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx +++ b/lms/static/scripts/frontend_apps/components/dashboard/AssignmentActivity.tsx @@ -6,13 +6,10 @@ import { } from '@hypothesis/frontend-shared'; import classnames from 'classnames'; import { useMemo } from 'preact/hooks'; -import { useParams } from 'wouter-preact'; import type { Assignment, StudentsResponse } from '../../api-types'; -import { useConfig } from '../../config'; import { apiCall, urlPath } from '../../utils/api'; import { formatDateTime } from '../../utils/date'; -import { useFetch } from '../../utils/fetch'; import { replaceURLParams } from '../../utils/url'; import type { LoaderOptions } from '../ComponentWithLoaderWrapper'; import DashboardBreadcrumbs from './DashboardBreadcrumbs';