Skip to content

Commit

Permalink
Add route-to-module map to dashboard
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Jun 20, 2024
1 parent 7253c62 commit e5d814d
Show file tree
Hide file tree
Showing 2 changed files with 111 additions and 38 deletions.
Original file line number Diff line number Diff line change
@@ -1,62 +1,138 @@
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<ConfigObject, 'dashboard' | 'api'>;
params: Record<string, string>;
signal: AbortSignal;
};

type RouteModule = {
loader: (opts: LoaderOptions) => Promise<unknown>;
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<string, () => 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<string, () => 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<ComponentChildren>();
const [loading, setLoading] = useState(true);
const [fatalError, setFatalError] = useState<ErrorLike>();

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(<Component loaderResult={loaderResult} params={params} />);
} 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(<Component loaderResult={loaderResult} />),
)
.catch(e => setFatalError(e))
.finally(() => setLoading(false));

return () => abortController.abort();
}, [assignmentId, courseId, organizationId, config, isAssignment, isCourse]);
}, [config, organizationId, routeModule]);

if (fatalError) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

0 comments on commit e5d814d

Please sign in to comment.