From 66c193b4d07788a2b8e7637db36736c62ddeafbf Mon Sep 17 00:00:00 2001
From: thomasbeaudry
Date: Sat, 3 Oct 2026 11:03:32 -0400
Subject: [PATCH 1/6] feat(api): summarize instrument records by subject
The subject listing needs a record count and a latest collection date per
subject, which it previously had no way to ask for: fetching every record in
the group to count them client-side does not scale, and the existing
`hasRecord` parameter answers only the boolean case.
Aggregation groups by group and subject rather than by subject alone so the
per-row `groupId` is available to the ability check, and the fold happens in JS
so a subject readable in two groups becomes one row with the counts added and
the later date kept. The grouped set is bounded by subjects, not records.
Co-Authored-By: Claude Opus 5
---
.../instrument-records.service.spec.ts | 84 ++++++++++++++++++
.../instrument-records.controller.ts | 7 ++
.../instrument-records.service.ts | 85 +++++++++++++++++++
.../instrument-records/instrument-records.ts | 8 ++
4 files changed, 184 insertions(+)
diff --git a/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts b/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts
index a9265f13f..ce94aff10 100644
--- a/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts
+++ b/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts
@@ -65,6 +65,7 @@ describe('InstrumentRecordsService', () => {
let instrumentRecordsService: InstrumentRecordsService;
let instrumentRecordModel: MockedInstance>;
let sessionModel: MockedInstance>;
+ let groupsService: MockedInstance;
let instrumentsService: MockedInstance;
let sessionsService: MockedInstance;
let subjectsService: MockedInstance;
@@ -111,6 +112,7 @@ describe('InstrumentRecordsService', () => {
instrumentRecordModel = moduleRef.get(getModelToken('InstrumentRecord'));
sessionModel = moduleRef.get(getModelToken('Session'));
instrumentRecordsService = moduleRef.get(InstrumentRecordsService);
+ groupsService = moduleRef.get(GroupsService);
instrumentsService = moduleRef.get(InstrumentsService);
sessionsService = moduleRef.get(SessionsService);
subjectsService = moduleRef.get(SubjectsService);
@@ -1211,4 +1213,86 @@ describe('InstrumentRecordsService', () => {
});
});
});
+
+ describe('summarizeBySubject', () => {
+ const ADMIN = createAppAbility([{ action: 'manage', subject: 'all' }]);
+
+ it('should verify the group exists before summarizing it, so an unknown group is a 404 rather than an empty result', async () => {
+ instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([]);
+
+ await instrumentRecordsService.summarizeBySubject({ groupId: 'group-1' }, { ability: ADMIN });
+
+ expect(groupsService.findById).toHaveBeenCalledWith('group-1');
+ });
+
+ // Mongodb omits a grouping expression that resolves to nothing from `_id`, and `$project` then
+ // drops the field from the row entirely. `$ifNull` is what keeps the row shape fixed, so the
+ // fold can be written against it rather than guarding for an absent field.
+ it('should normalise every grouping key with $ifNull, so a missing field arrives as null rather than absent', async () => {
+ instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([]);
+
+ await instrumentRecordsService.summarizeBySubject({}, { ability: ADMIN });
+
+ const [{ pipeline }] = instrumentRecordModel.aggregateRaw.mock.lastCall as [{ pipeline: any[] }];
+ expect(pipeline.find((stage) => stage.$group).$group._id).toStrictEqual({
+ groupId: { $ifNull: ['$groupId', null] },
+ subjectId: { $ifNull: ['$subjectId', null] }
+ });
+ });
+
+ it('should key the summary on the subject, so the subject hub can read a row per subject', async () => {
+ instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([
+ { groupId: 'group-1', lastCollectedAt: '2025-02-02T00:00:00.000Z', recordCount: 4, subjectId: 'subject-1' }
+ ]);
+
+ const [summary] = await instrumentRecordsService.summarizeBySubject({}, { ability: ADMIN });
+
+ expect(summary).toStrictEqual({
+ lastCollectedAt: new Date('2025-02-02T00:00:00.000Z'),
+ recordCount: 4,
+ subjectId: 'subject-1'
+ });
+ });
+
+ it("should drop rows from a group the caller cannot read, so a shared subject does not leak another group's count", async () => {
+ const ability = createAppAbility([
+ { action: 'read', conditions: { groupId: 'group-1' }, subject: 'InstrumentRecord' }
+ ]);
+ instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([
+ { groupId: 'group-1', lastCollectedAt: null, recordCount: 2, subjectId: 'subject-1' },
+ { groupId: 'group-2', lastCollectedAt: null, recordCount: 7, subjectId: 'subject-1' }
+ ]);
+
+ const [summary] = await instrumentRecordsService.summarizeBySubject({}, { ability });
+
+ expect(summary).toMatchObject({ recordCount: 2, subjectId: 'subject-1' });
+ });
+
+ // A subject seen in two readable groups is one row in the table, so the counts add and the date
+ // is the later of the two rather than whichever arrived last.
+ it('should fold a subject appearing in several readable groups into one row', async () => {
+ instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([
+ { groupId: 'group-1', lastCollectedAt: '2025-01-01T00:00:00.000Z', recordCount: 2, subjectId: 'subject-1' },
+ { groupId: 'group-2', lastCollectedAt: '2025-03-03T00:00:00.000Z', recordCount: 3, subjectId: 'subject-1' }
+ ]);
+
+ const summaries = await instrumentRecordsService.summarizeBySubject({}, { ability: ADMIN });
+
+ expect(summaries).toStrictEqual([
+ {
+ lastCollectedAt: new Date('2025-03-03T00:00:00.000Z'),
+ recordCount: 5,
+ subjectId: 'subject-1'
+ }
+ ]);
+ });
+
+ it('should skip a row whose subject could not be resolved, rather than keying a summary on null', async () => {
+ instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([
+ { groupId: 'group-1', lastCollectedAt: null, recordCount: 2, subjectId: null }
+ ]);
+
+ expect(await instrumentRecordsService.summarizeBySubject({}, { ability: ADMIN })).toStrictEqual([]);
+ });
+ });
});
diff --git a/apps/api/src/instrument-records/instrument-records.controller.ts b/apps/api/src/instrument-records/instrument-records.controller.ts
index b7081f09a..e6ff8bbc3 100644
--- a/apps/api/src/instrument-records/instrument-records.controller.ts
+++ b/apps/api/src/instrument-records/instrument-records.controller.ts
@@ -94,6 +94,13 @@ export class InstrumentRecordsController {
return this.instrumentRecordsService.linearModel({ groupId, instrumentId }, { ability });
}
+ @ApiOperation({ description: 'Per-subject record counts', summary: 'Summarize Records By Subject' })
+ @Get('summary/by-subject')
+ @RouteAccess({ action: 'read', subject: 'InstrumentRecord' })
+ summarizeBySubject(@CurrentUser('ability') ability: AppAbility, @Query('groupId') groupId?: string) {
+ return this.instrumentRecordsService.summarizeBySubject({ groupId }, { ability });
+ }
+
@ApiOperation({ summary: 'Update Instrument Record' })
@Patch(':id')
@RouteAccess({ action: 'update', subject: 'InstrumentRecord' })
diff --git a/apps/api/src/instrument-records/instrument-records.service.ts b/apps/api/src/instrument-records/instrument-records.service.ts
index 806acb3d2..7fbfd0dda 100644
--- a/apps/api/src/instrument-records/instrument-records.service.ts
+++ b/apps/api/src/instrument-records/instrument-records.service.ts
@@ -21,6 +21,7 @@ import type {
InstrumentRecordQueryParams,
InstrumentRecordsExport,
LinearRegressionResults,
+ SubjectRecordSummary,
UploadInstrumentRecordsData
} from '@opendatacapture/schemas/instrument-records';
import { Prisma } from '@prisma/client';
@@ -363,6 +364,90 @@ export class InstrumentRecordsService {
return results;
}
+ /**
+ * Per-subject record counts and latest collection date, for the subject hub's listing.
+ *
+ * Aggregation groups by group and subject rather than by subject alone, and the final fold happens
+ * here in JS, because the per-row `groupId` is what the ability check needs. The grouped set is
+ * bounded by subjects, not by records.
+ */
+ async summarizeBySubject(
+ { groupId }: { groupId?: string } = {},
+ { ability }: Required>
+ ): Promise {
+ if (groupId) {
+ await this.groupsService.findById(groupId);
+ }
+
+ const pipeline = [
+ {
+ $match: {
+ // records created before the file instrument feature do not have the field at all
+ $or: [{ pending: { $exists: false } }, { pending: null }, { pending: false }],
+ ...(groupId ? { $expr: { $eq: ['$groupId', { $toObjectId: groupId }] } } : {})
+ }
+ },
+ {
+ // `$ifNull` is load-bearing, not defensive. A grouping expression that resolves to nothing —
+ // `groupId` on a record belonging to no group — is omitted from `_id` entirely rather than
+ // stored as null, and `$project` then omits the field from the row too. Normalising here is
+ // what lets the fold below treat the shape as fixed instead of remembering a key can simply
+ // be missing.
+ $group: {
+ _id: {
+ groupId: { $ifNull: ['$groupId', null] },
+ subjectId: { $ifNull: ['$subjectId', null] }
+ },
+ lastCollectedAt: { $max: '$date' },
+ recordCount: { $sum: 1 }
+ }
+ },
+ {
+ $project: {
+ _id: 0,
+ groupId: { $toString: '$_id.groupId' },
+ lastCollectedAt: { $dateToString: { date: '$lastCollectedAt' } },
+ recordCount: 1,
+ subjectId: '$_id.subjectId'
+ }
+ }
+ ];
+
+ const rows = (await this.instrumentRecordModel.aggregateRaw({ pipeline })) as unknown as {
+ groupId: null | string;
+ lastCollectedAt: null | string;
+ recordCount: number;
+ subjectId: null | string;
+ }[];
+
+ const summaries = new Map();
+ for (const row of rows) {
+ // Raw rows carry no model name, so CASL would resolve them as `Object` and match only `manage all`
+ if (!ability.can('read', forcedAppSubject('InstrumentRecord', { groupId: row.groupId }))) {
+ continue;
+ }
+ if (row.subjectId === null) {
+ continue;
+ }
+ let summary = summaries.get(row.subjectId);
+ if (!summary) {
+ summary = { lastCollectedAt: null, recordCount: 0 };
+ summaries.set(row.subjectId, summary);
+ }
+ summary.recordCount += row.recordCount;
+ const lastCollectedAt = row.lastCollectedAt ? new Date(row.lastCollectedAt) : null;
+ if (lastCollectedAt && (!summary.lastCollectedAt || lastCollectedAt > summary.lastCollectedAt)) {
+ summary.lastCollectedAt = lastCollectedAt;
+ }
+ }
+
+ return Array.from(summaries, ([subjectId, summary]) => ({
+ lastCollectedAt: summary.lastCollectedAt,
+ recordCount: summary.recordCount,
+ subjectId
+ }));
+ }
+
async updateById(id: string, data: unknown[] | { [key: string]: unknown }, { ability }: EntityOperationOptions = {}) {
const instrumentRecord = await this.instrumentRecordModel.findFirst({
where: { id }
diff --git a/packages/schemas/src/instrument-records/instrument-records.ts b/packages/schemas/src/instrument-records/instrument-records.ts
index f06d0283a..a349739bf 100644
--- a/packages/schemas/src/instrument-records/instrument-records.ts
+++ b/packages/schemas/src/instrument-records/instrument-records.ts
@@ -110,3 +110,11 @@ export const $InstrumentRecordFile = $FileMetadata.omit({ location: true }).exte
export type $InstrumentRecordFiles = z.infer;
export const $InstrumentRecordFiles = z.record(z.string(), z.array($InstrumentRecordFile));
+
+export type SubjectRecordSummary = z.infer;
+export const $SubjectRecordSummary = z.object({
+ /** The most recent record date, or null for a subject whose records all lack one */
+ lastCollectedAt: z.coerce.date().nullable(),
+ recordCount: z.int().nonnegative(),
+ subjectId: z.string()
+});
From d4bf375592ace282ca8d2c234cf854ca5eb4048f Mon Sep 17 00:00:00 2001
From: thomasbeaudry
Date: Sat, 3 Oct 2026 11:03:47 -0400
Subject: [PATCH 2/6] feat(web): move the datahub behind a subject hub
The datahub was a single page listing subjects, with no room for a second way
into the same records. It becomes a nav group whose only child today is the
subject view at /datahub/subjects; /datahub redirects there so existing links
and bookmarks keep working, and the per-subject routes move underneath.
The listing itself gains what the new summary endpoint supplies: a record count
and a last-collected date, both sortable, plus a minimum-count filter and a
collection-date window. The minimum count replaces the old "with records only"
checkbox, which was the same question with one answer. It narrows on the counts
already loaded for the column rather than refetching, because re-querying
suspended the table and left the stale set on screen a keystroke behind.
Sex becomes a colour-tagged cell. The colour is always redundant to the label
beside it, so it stays readable without relying on hue.
TabLink moves out of the subject layout into its own component, unchanged, so
the next view into the datahub can reuse it rather than copying it.
Co-Authored-By: Claude Opus 5
---
.../__tests__/gateway-route-guards.test.ts | 38 +-
.../src/__tests__/sortable-header.test.tsx | 47 ++
.../components/ColorTagCell/ColorTagCell.tsx | 25 +
apps/web/src/components/ColorTagCell/index.ts | 1 +
.../SortableHeader/SortableHeader.tsx | 27 +
.../src/components/SortableHeader/index.ts | 1 +
apps/web/src/components/TabLink/TabLink.tsx | 27 +
apps/web/src/components/TabLink/index.ts | 1 +
.../src/hooks/__tests__/useNavItems.test.ts | 16 +
.../useSubjectRecordSummaryQuery.test.ts | 53 ++
apps/web/src/hooks/useNavItems.ts | 16 +-
.../src/hooks/useSubjectRecordSummaryQuery.ts | 21 +
.../web/src/providers/WalkthroughProvider.tsx | 28 +-
apps/web/src/routes/_app/dashboard.tsx | 2 +-
apps/web/src/routes/_app/datahub/index.tsx | 521 +------------
.../{ => subjects}/$subjectId/assignments.tsx | 4 +-
.../{ => subjects}/$subjectId/graph.tsx | 2 +-
.../{ => subjects}/$subjectId/route.tsx | 27 +-
.../$subjectId/table/$recordId.tsx | 2 +-
.../{ => subjects}/$subjectId/table/index.tsx | 2 +-
.../routes/_app/datahub/subjects/index.tsx | 705 ++++++++++++++++++
apps/web/src/utils/tag-colors.ts | 29 +
.../$subjectId/assignments.page.ts | 2 +-
.../{ => subjects}/$subjectId/graph.page.ts | 2 +-
.../$subjectId/table/$recordId.page.ts | 2 +-
.../$subjectId/table/index.page.ts | 2 +-
.../_app/datahub/{ => subjects}/index.page.ts | 34 +-
testing/src/specs/authorization.spec.ts | 11 +-
testing/src/specs/dashboard.spec.ts | 2 +-
testing/src/specs/datahub.spec.ts | 79 +-
testing/src/specs/error-page.spec.ts | 4 +-
testing/src/specs/gateway-assignment.spec.ts | 6 +-
.../src/specs/instrument-completion.spec.ts | 16 +-
testing/src/specs/remote-assignment.spec.ts | 14 +-
testing/src/specs/series-instrument.spec.ts | 4 +-
testing/src/specs/subject-detail.spec.ts | 16 +-
testing/src/specs/upload.spec.ts | 2 +-
testing/src/support/fixtures.ts | 20 +-
38 files changed, 1161 insertions(+), 650 deletions(-)
create mode 100644 apps/web/src/__tests__/sortable-header.test.tsx
create mode 100644 apps/web/src/components/ColorTagCell/ColorTagCell.tsx
create mode 100644 apps/web/src/components/ColorTagCell/index.ts
create mode 100644 apps/web/src/components/SortableHeader/SortableHeader.tsx
create mode 100644 apps/web/src/components/SortableHeader/index.ts
create mode 100644 apps/web/src/components/TabLink/TabLink.tsx
create mode 100644 apps/web/src/components/TabLink/index.ts
create mode 100644 apps/web/src/hooks/__tests__/useSubjectRecordSummaryQuery.test.ts
create mode 100644 apps/web/src/hooks/useSubjectRecordSummaryQuery.ts
rename apps/web/src/routes/_app/datahub/{ => subjects}/$subjectId/assignments.tsx (97%)
rename apps/web/src/routes/_app/datahub/{ => subjects}/$subjectId/graph.tsx (98%)
rename apps/web/src/routes/_app/datahub/{ => subjects}/$subjectId/route.tsx (68%)
rename apps/web/src/routes/_app/datahub/{ => subjects}/$subjectId/table/$recordId.tsx (98%)
rename apps/web/src/routes/_app/datahub/{ => subjects}/$subjectId/table/index.tsx (97%)
create mode 100644 apps/web/src/routes/_app/datahub/subjects/index.tsx
create mode 100644 apps/web/src/utils/tag-colors.ts
rename testing/src/pages/_app/datahub/{ => subjects}/$subjectId/assignments.page.ts (91%)
rename testing/src/pages/_app/datahub/{ => subjects}/$subjectId/graph.page.ts (97%)
rename testing/src/pages/_app/datahub/{ => subjects}/$subjectId/table/$recordId.page.ts (91%)
rename testing/src/pages/_app/datahub/{ => subjects}/$subjectId/table/index.page.ts (75%)
rename testing/src/pages/_app/datahub/{ => subjects}/index.page.ts (62%)
diff --git a/apps/web/src/__tests__/gateway-route-guards.test.ts b/apps/web/src/__tests__/gateway-route-guards.test.ts
index 1ee4269a6..d4f72c682 100644
--- a/apps/web/src/__tests__/gateway-route-guards.test.ts
+++ b/apps/web/src/__tests__/gateway-route-guards.test.ts
@@ -19,9 +19,9 @@ const GATEWAY_ROUTES = [
redirectsTo: '/dashboard'
},
{
- importRoute: async () => (await import('@/routes/_app/datahub/$subjectId/assignments')).Route,
+ importRoute: async () => (await import('@/routes/_app/datahub/subjects/$subjectId/assignments')).Route,
params: { subjectId: '123' },
- redirectsTo: '/datahub/$subjectId/table'
+ redirectsTo: '/datahub/subjects/$subjectId/table'
},
{
importRoute: async () => (await import('@/routes/_app/group/email-templates')).Route,
@@ -63,17 +63,33 @@ beforeEach(() => {
mocks.config.setup.isGatewayEnabled = true;
});
+/**
+ * Each case dynamically imports a real route module, which pulls in that page's whole component
+ * graph on first touch. Under the full suite's parallel load that cold transform has repeatedly
+ * exceeded vitest's 5s default, so these cases carry their own budget: the import cost is the
+ * fixture, not the thing under test.
+ */
+const IMPORT_TIMEOUT = 30_000;
+
describe.each(GATEWAY_ROUTES)('$redirectsTo guard', ({ importRoute, params, redirectsTo }) => {
- it('should redirect away when the gateway is not deployed, so a bookmarked link cannot reach a page whose endpoints are not mounted', async () => {
- mocks.config.setup.isGatewayEnabled = false;
- const thrown = await runGuard(await importRoute(), params);
- expect(isRedirect(thrown)).toBe(true);
- expect((thrown as { options: { to: string } }).options.to).toBe(redirectsTo);
- });
+ it(
+ 'should redirect away when the gateway is not deployed, so a bookmarked link cannot reach a page whose endpoints are not mounted',
+ async () => {
+ mocks.config.setup.isGatewayEnabled = false;
+ const thrown = await runGuard(await importRoute(), params);
+ expect(isRedirect(thrown)).toBe(true);
+ expect((thrown as { options: { to: string } }).options.to).toBe(redirectsTo);
+ },
+ IMPORT_TIMEOUT
+ );
- it('should allow the route when the gateway is deployed', async () => {
- expect(await runGuard(await importRoute(), params)).toBeNull();
- });
+ it(
+ 'should allow the route when the gateway is deployed',
+ async () => {
+ expect(await runGuard(await importRoute(), params)).toBeNull();
+ },
+ IMPORT_TIMEOUT
+ );
});
describe('bulk remote assignments guard', () => {
diff --git a/apps/web/src/__tests__/sortable-header.test.tsx b/apps/web/src/__tests__/sortable-header.test.tsx
new file mode 100644
index 000000000..1a83c48af
--- /dev/null
+++ b/apps/web/src/__tests__/sortable-header.test.tsx
@@ -0,0 +1,47 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { SortableHeader } from '@/components/SortableHeader';
+
+type Row = { id: string };
+
+const columnOf = (sorted: 'asc' | 'desc' | false, toggleSorting = vi.fn()) =>
+ ({ getIsSorted: () => sorted, toggleSorting }) as never;
+
+describe('SortableHeader', () => {
+ it('should render the column label', () => {
+ render( column={columnOf(false)} label="Instrument" />);
+ expect(screen.getByRole('button', { name: /Instrument/u })).toBeTruthy();
+ });
+
+ it('should ask the column to toggle its sort when clicked', () => {
+ const toggleSorting = vi.fn();
+ render( column={columnOf(false, toggleSorting)} label="Records" />);
+
+ fireEvent.click(screen.getByRole('button', { name: /Records/u }));
+
+ expect(toggleSorting).toHaveBeenCalledTimes(1);
+ });
+
+ // The icon is the only thing distinguishing the three states, so it has to change with them.
+ it('should show a distinct icon for unsorted, ascending and descending', () => {
+ const iconOf = (sorted: 'asc' | 'desc' | false) => {
+ const { container, unmount } = render( column={columnOf(sorted)} label="Subjects" />);
+ const path = container.querySelector('svg')?.innerHTML ?? '';
+ unmount();
+ return path;
+ };
+
+ const [unsorted, ascending, descending] = [iconOf(false), iconOf('asc'), iconOf('desc')];
+ expect(new Set([ascending, descending, unsorted]).size).toBe(3);
+ });
+
+ // Dimming is what tells a user which column the table is actually ordered by.
+ it('should dim the icon only while the column is unsorted', () => {
+ const { container: unsorted } = render( column={columnOf(false)} label="A" />);
+ expect(unsorted.querySelector('svg')?.getAttribute('class')).toContain('opacity-40');
+
+ const { container: sorted } = render( column={columnOf('asc')} label="B" />);
+ expect(sorted.querySelector('svg')?.getAttribute('class')).not.toContain('opacity-40');
+ });
+});
diff --git a/apps/web/src/components/ColorTagCell/ColorTagCell.tsx b/apps/web/src/components/ColorTagCell/ColorTagCell.tsx
new file mode 100644
index 000000000..35eccecb2
--- /dev/null
+++ b/apps/web/src/components/ColorTagCell/ColorTagCell.tsx
@@ -0,0 +1,25 @@
+type ColorTagCellProps = {
+ color: string;
+ 'data-testid'?: string;
+ label: string;
+};
+
+/**
+ * A table cell tagging a nominal value with a colour: a dot carrying the colour, and the name beside
+ * it in ordinary ink.
+ *
+ * The colour is always redundant — the label is right there — which is what keeps it readable for a
+ * colourblind user and why the text itself is never tinted. The label clips rather than wrapping, so
+ * one long value cannot make every row in the table taller.
+ */
+export const ColorTagCell = ({ color, 'data-testid': testId, label }: ColorTagCellProps) => (
+
+ {/* The hairline ring is what lets a pastel fill stay visible against a light surface, where a
+ bare dot of it would sit near 1.5:1 and effectively disappear. */}
+
+ {label}
+
+);
diff --git a/apps/web/src/components/ColorTagCell/index.ts b/apps/web/src/components/ColorTagCell/index.ts
new file mode 100644
index 000000000..3eb31280a
--- /dev/null
+++ b/apps/web/src/components/ColorTagCell/index.ts
@@ -0,0 +1 @@
+export * from './ColorTagCell';
diff --git a/apps/web/src/components/SortableHeader/SortableHeader.tsx b/apps/web/src/components/SortableHeader/SortableHeader.tsx
new file mode 100644
index 000000000..487b50557
--- /dev/null
+++ b/apps/web/src/components/SortableHeader/SortableHeader.tsx
@@ -0,0 +1,27 @@
+import { TanstackTable } from '@douglasneuroinformatics/libui/components';
+import { cn } from '@douglasneuroinformatics/libui/utils';
+import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react';
+
+type SortableHeaderProps = {
+ column: TanstackTable.Column;
+ label: string;
+};
+
+/**
+ * A clickable column label. `DataTableHead` renders whatever the column supplies, so the sort
+ * affordance lives here rather than coming from the table.
+ */
+export const SortableHeader = ({ column, label }: SortableHeaderProps) => {
+ const sorted = column.getIsSorted();
+ const Icon = sorted === 'asc' ? ChevronUpIcon : sorted === 'desc' ? ChevronDownIcon : ChevronsUpDownIcon;
+ return (
+ column.toggleSorting()}
+ >
+ {label}
+
+
+ );
+};
diff --git a/apps/web/src/components/SortableHeader/index.ts b/apps/web/src/components/SortableHeader/index.ts
new file mode 100644
index 000000000..563341a1b
--- /dev/null
+++ b/apps/web/src/components/SortableHeader/index.ts
@@ -0,0 +1 @@
+export * from './SortableHeader';
diff --git a/apps/web/src/components/TabLink/TabLink.tsx b/apps/web/src/components/TabLink/TabLink.tsx
new file mode 100644
index 000000000..a6a9156f0
--- /dev/null
+++ b/apps/web/src/components/TabLink/TabLink.tsx
@@ -0,0 +1,27 @@
+import { cn } from '@douglasneuroinformatics/libui/utils';
+import { Link, useLocation } from '@tanstack/react-router';
+
+type TabLinkProps = {
+ label: string;
+ pathname: string;
+ testId?: string;
+};
+
+export const TabLink = ({ label, pathname, testId }: TabLinkProps) => {
+ const location = useLocation();
+ const isActive = location.pathname.startsWith(pathname);
+ return (
+
+ {label}
+
+ );
+};
diff --git a/apps/web/src/components/TabLink/index.ts b/apps/web/src/components/TabLink/index.ts
new file mode 100644
index 000000000..840703928
--- /dev/null
+++ b/apps/web/src/components/TabLink/index.ts
@@ -0,0 +1 @@
+export * from './TabLink';
diff --git a/apps/web/src/hooks/__tests__/useNavItems.test.ts b/apps/web/src/hooks/__tests__/useNavItems.test.ts
index f8dc239fe..ed0fbd53a 100644
--- a/apps/web/src/hooks/__tests__/useNavItems.test.ts
+++ b/apps/web/src/hooks/__tests__/useNavItems.test.ts
@@ -101,6 +101,22 @@ describe('useNavItems', () => {
expect(navLabels()).toContain('Group Actions');
});
+ // The datahub is a parent over its views rather than a link of its own, so the subject listing is
+ // reachable only as a child and the group itself carries no url to navigate to.
+ it('should offer the subject view nested under the data hub parent', () => {
+ const datahub = renderHook(() => useNavItems())
+ .result.current.flat()
+ .find((item) => item.label === 'Data Hub');
+ expect(datahub?.url).toBeUndefined();
+ expect(datahub?.children?.map((child) => child.url)).toStrictEqual(['/datahub/subjects']);
+ });
+
+ it('should omit the data hub entirely when the user cannot read both subjects and records', () => {
+ mocks.can.mockImplementation((action, subject) => !(action === 'read' && subject === 'InstrumentRecord'));
+ expect(navLabels()).not.toContain('Data Hub');
+ expect(navUrls()).not.toContain('/datahub/subjects');
+ });
+
it('should offer bulk remote assignments to a user holding the abilities that page needs', () => {
expect(navUrls()).toContain('/group/remote-assignments');
});
diff --git a/apps/web/src/hooks/__tests__/useSubjectRecordSummaryQuery.test.ts b/apps/web/src/hooks/__tests__/useSubjectRecordSummaryQuery.test.ts
new file mode 100644
index 000000000..0ad2825a2
--- /dev/null
+++ b/apps/web/src/hooks/__tests__/useSubjectRecordSummaryQuery.test.ts
@@ -0,0 +1,53 @@
+import { QueryClient } from '@tanstack/react-query';
+import axios from 'axios';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { subjectRecordSummaryQueryOptions } from '../useSubjectRecordSummaryQuery';
+
+vi.mock('axios');
+
+const runQuery = (groupId?: string) =>
+ new QueryClient().fetchQuery(subjectRecordSummaryQueryOptions({ params: { groupId } }));
+
+// eslint-disable-next-line @typescript-eslint/unbound-method -- a vitest mock, never invoked as a method
+const get = vi.mocked(axios).get;
+
+describe('subjectRecordSummaryQueryOptions', () => {
+ beforeEach(() => {
+ get.mockReset();
+ });
+
+ it('should scope the summary to the current group, so the counts match the subjects listed beside them', async () => {
+ get.mockResolvedValueOnce({ data: [] });
+ await runQuery('group-1');
+ expect(get).toHaveBeenCalledWith('/v1/instrument-records/summary/by-subject', { params: { groupId: 'group-1' } });
+ });
+
+ it('should coerce the collection date, so the column can compare it as a date rather than a string', async () => {
+ get.mockResolvedValueOnce({
+ data: [{ lastCollectedAt: '2025-02-02T00:00:00.000Z', recordCount: 3, subjectId: 'subject-1' }]
+ });
+ const [summary] = await runQuery('group-1');
+ expect(summary!.lastCollectedAt).toStrictEqual(new Date('2025-02-02T00:00:00.000Z'));
+ });
+
+ // A subject the group has never collected from still belongs in the list, so the date is nullable
+ // rather than the row being absent.
+ it('should accept a null collection date for a subject with no records yet', async () => {
+ get.mockResolvedValueOnce({ data: [{ lastCollectedAt: null, recordCount: 0, subjectId: 'subject-1' }] });
+ await expect(runQuery('group-1')).resolves.toStrictEqual([
+ { lastCollectedAt: null, recordCount: 0, subjectId: 'subject-1' }
+ ]);
+ });
+
+ it('should reject a negative record count, so nothing unparsed reaches the table', async () => {
+ get.mockResolvedValueOnce({ data: [{ lastCollectedAt: null, recordCount: -1, subjectId: 'subject-1' }] });
+ await expect(runQuery('group-1')).rejects.toThrow();
+ });
+
+ it('should key the cache on the group, so switching group does not serve the previous group counts', () => {
+ expect(subjectRecordSummaryQueryOptions({ params: { groupId: 'group-1' } }).queryKey).not.toStrictEqual(
+ subjectRecordSummaryQueryOptions({ params: { groupId: 'group-2' } }).queryKey
+ );
+ });
+});
diff --git a/apps/web/src/hooks/useNavItems.ts b/apps/web/src/hooks/useNavItems.ts
index b80094936..143eecac2 100644
--- a/apps/web/src/hooks/useNavItems.ts
+++ b/apps/web/src/hooks/useNavItems.ts
@@ -70,11 +70,21 @@ export function useNavItems() {
url: '/dashboard'
});
}
+ // The datahub becomes a group so its views can be read along different axes. Only the subject
+ // view exists today; the instrument one lands next and joins this group beside it.
+ const datahubItems: NavItem[] = [];
if (ability?.can('read', 'Subject') && ability.can('read', 'InstrumentRecord')) {
+ datahubItems.push({
+ icon: UsersIcon,
+ label: t({ en: 'Subjects', es: 'Sujetos', fr: 'Clients' }),
+ url: '/datahub/subjects'
+ });
+ }
+ if (datahubItems.length > 0) {
globalItems.push({
+ children: datahubItems,
icon: DatabaseIcon,
- label: t('layout.navLinks.datahub'),
- url: '/datahub'
+ label: t('layout.navLinks.datahub')
});
}
if (
@@ -269,7 +279,7 @@ export function useNavItems() {
disabled: currentSession === null,
icon: EyeIcon,
label: t('layout.navLinks.viewCurrentSubject'),
- url: `/datahub/${currentSession?.subjectId}/table`
+ url: `/datahub/subjects/${currentSession?.subjectId}/table`
});
}
setNavItems([globalItems, adminItems, sessionItems].filter((arr) => arr.length));
diff --git a/apps/web/src/hooks/useSubjectRecordSummaryQuery.ts b/apps/web/src/hooks/useSubjectRecordSummaryQuery.ts
new file mode 100644
index 000000000..590881769
--- /dev/null
+++ b/apps/web/src/hooks/useSubjectRecordSummaryQuery.ts
@@ -0,0 +1,21 @@
+import { $SubjectRecordSummary } from '@opendatacapture/schemas/instrument-records';
+import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
+import axios from 'axios';
+
+type SubjectRecordSummaryQueryParams = {
+ groupId?: string;
+};
+
+export const subjectRecordSummaryQueryOptions = ({ params }: { params?: SubjectRecordSummaryQueryParams } = {}) => {
+ return queryOptions({
+ queryFn: async () => {
+ const response = await axios.get('/v1/instrument-records/summary/by-subject', { params });
+ return $SubjectRecordSummary.array().parse(response.data);
+ },
+ queryKey: ['subject-record-summary', params?.groupId]
+ });
+};
+
+export function useSubjectRecordSummaryQuery({ params }: { params?: SubjectRecordSummaryQueryParams } = {}) {
+ return useSuspenseQuery(subjectRecordSummaryQueryOptions({ params }));
+}
diff --git a/apps/web/src/providers/WalkthroughProvider.tsx b/apps/web/src/providers/WalkthroughProvider.tsx
index 3c7da14bb..1ae649f82 100644
--- a/apps/web/src/providers/WalkthroughProvider.tsx
+++ b/apps/web/src/providers/WalkthroughProvider.tsx
@@ -134,10 +134,10 @@ const Walkthrough = () => {
),
navigateOptions: {
- to: '/datahub'
+ to: '/datahub/subjects'
},
position: 'bottom-left',
- target: 'button[data-nav-url="/datahub"]',
+ target: 'button[data-nav-url="/datahub/subjects"]',
title: t({
en: 'Data Hub',
es: 'Centro de datos',
@@ -155,7 +155,7 @@ const Walkthrough = () => {
),
navigateOptions: {
- to: '/datahub'
+ to: '/datahub/subjects'
},
position: 'bottom-left',
target: '#datahub-subject-search-bar',
@@ -176,7 +176,7 @@ const Walkthrough = () => {
),
navigateOptions: {
- to: '/datahub'
+ to: '/datahub/subjects'
},
position: 'bottom-left',
target: '[data-spotlight-type="subject-lookup-search-button"]',
@@ -193,7 +193,7 @@ const Walkthrough = () => {
fr: 'Ici, vous pouvez exporter toutes vos données dans différents formats.'
}),
navigateOptions: {
- to: '/datahub'
+ to: '/datahub/subjects'
},
position: 'bottom-right',
target: '[data-spotlight-type="export-data-dropdown"]',
@@ -321,10 +321,10 @@ const Walkthrough = () => {
fr: "Sur cette page, vous pouvez consulter les données du client pour lequel la session est en cours. Pour accéder aux données d'autres clients, utilisez le bouton de recherche sur la page du centre de données."
}),
navigateOptions: {
- to: '/datahub/123/table'
+ to: '/datahub/subjects/123/table'
},
position: 'bottom-left',
- target: 'button[data-nav-url="/datahub/123/table"]',
+ target: 'button[data-nav-url="/datahub/subjects/123/table"]',
title: t({
en: 'View Subject',
es: 'Ver el sujeto',
@@ -338,10 +338,10 @@ const Walkthrough = () => {
fr: 'Ici, vous pouvez voir les enregistrements que ce client a complétés pour un instrument donné'
}),
navigateOptions: {
- to: '/datahub/123/table'
+ to: '/datahub/subjects/123/table'
},
position: 'bottom-center',
- target: 'a[data-nav-url="/datahub/123/table"]',
+ target: 'a[data-nav-url="/datahub/subjects/123/table"]',
title: t({
en: 'Table',
es: 'Tabla',
@@ -355,7 +355,7 @@ const Walkthrough = () => {
fr: 'Ici, vous pouvez exporter les données du tableau au format CSV ou JSON.'
}),
navigateOptions: {
- to: '/datahub/123/table'
+ to: '/datahub/subjects/123/table'
},
position: 'bottom-right',
target: 'div[data-spotlight-type="export-data-dropdown"]',
@@ -372,10 +372,10 @@ const Walkthrough = () => {
fr: "Ici, vous pouvez créer des graphiques personnalisés pour visualiser les données longitudinales d'un client donné."
}),
navigateOptions: {
- to: '/datahub/123/graph'
+ to: '/datahub/subjects/123/graph'
},
position: 'bottom-right',
- target: 'a[data-nav-url="/datahub/123/graph"]',
+ target: 'a[data-nav-url="/datahub/subjects/123/graph"]',
title: t({
en: 'Graph',
es: 'Gráfico',
@@ -391,10 +391,10 @@ const Walkthrough = () => {
fr: 'Ici, vous pouvez créer et visualiser des assignations, qui sont des instruments que le client doit compléter à la maison.'
}),
navigateOptions: {
- to: '/datahub/123/assignments'
+ to: '/datahub/subjects/123/assignments'
},
position: 'bottom-left',
- target: 'a[data-nav-url="/datahub/123/assignments"]',
+ target: 'a[data-nav-url="/datahub/subjects/123/assignments"]',
title: t({
en: 'Assignments',
es: 'Asignaciones',
diff --git a/apps/web/src/routes/_app/dashboard.tsx b/apps/web/src/routes/_app/dashboard.tsx
index fb7807236..d6ba81e5a 100644
--- a/apps/web/src/routes/_app/dashboard.tsx
+++ b/apps/web/src/routes/_app/dashboard.tsx
@@ -230,7 +230,7 @@ const RouteComponent = () => {
data-testid="statistic-subjects"
onClick={() => {
void navigate({
- to: '/datahub'
+ to: '/datahub/subjects'
});
}}
>
diff --git a/apps/web/src/routes/_app/datahub/index.tsx b/apps/web/src/routes/_app/datahub/index.tsx
index 47ccaf180..66e0b4b11 100644
--- a/apps/web/src/routes/_app/datahub/index.tsx
+++ b/apps/web/src/routes/_app/datahub/index.tsx
@@ -1,522 +1,7 @@
-import React, { useMemo, useRef, useState } from 'react';
-
-import { toBasicISOString } from '@douglasneuroinformatics/libjs';
-import {
- ActionDropdown,
- Button,
- DataTable,
- Dialog,
- DropdownMenu,
- Heading
-} from '@douglasneuroinformatics/libui/components';
-import type { TanstackTable } from '@douglasneuroinformatics/libui/components';
-import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks';
-import type { InstrumentRecordsExport } from '@opendatacapture/schemas/instrument-records';
-import type { Sex, Subject } from '@opendatacapture/schemas/subject';
-import { removeSubjectIdScope } from '@opendatacapture/subject-utils';
-import { createFileRoute, useNavigate } from '@tanstack/react-router';
-import axios from 'axios';
-import { ChevronDownIcon, UserSearchIcon } from 'lucide-react';
-import { unpack } from 'msgpackr/unpack';
-import { unparse } from 'papaparse';
-
-import { IdentificationForm } from '@/components/IdentificationForm';
-import { PageHeader } from '@/components/PageHeader';
-import { SearchSubmitGuard } from '@/components/SearchSubmitGuard';
-import { subjectsQueryOptions, useSubjectsQuery } from '@/hooks/useSubjectsQuery';
-import { useAppStore } from '@/store';
-import { downloadExcel } from '@/utils/excel';
-import { getListedSubjectIds } from '@/utils/table';
-
-type DateFilter = {
- allowNull: boolean;
- max: Date | null;
- min: Date | null;
-};
-
-type SexFilter = (null | Sex)[];
-
-type HasSearchStringFilter = {
- searchString: string;
-};
-
-const Filters: React.FC<{
- hasRecords: boolean;
- setHasRecords: (v: boolean) => void;
- table: TanstackTable.Table;
-}> = ({ hasRecords, setHasRecords, table }) => {
- const { t } = useTranslation();
-
- const [isOpen, setIsOpen] = useState(false);
-
- const columns = table.getAllColumns();
-
- const dobColumn = columns.find((column) => column.id === 'dateOfBirth')!;
- const dobFilter = dobColumn.getFilterValue() as DateFilter;
-
- const sexColumn = columns.find((column) => column.id === 'sex')!;
- const sexFilter = sexColumn.getFilterValue() as SexFilter;
-
- return (
-
-
-
- {t('common.filters')}
-
-
-
-
- {t('core.identificationData.sex.label')}
-
- {
- sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => {
- if (checked) {
- return [...prevValue, 'MALE'];
- }
- return prevValue.filter((item) => item !== 'MALE');
- });
- }}
- onSelect={(e) => e.preventDefault()}
- >
- {t('core.identificationData.sex.male')}
-
- {
- sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => {
- if (checked) {
- return [...prevValue, 'FEMALE'];
- }
- return prevValue.filter((item) => item !== 'FEMALE');
- });
- }}
- onSelect={(e) => e.preventDefault()}
- >
- {t('core.identificationData.sex.female')}
-
- {
- sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => {
- if (checked) {
- return [...prevValue, null];
- }
- return prevValue.filter((item) => item !== null);
- });
- }}
- onSelect={(e) => e.preventDefault()}
- >
- {t({ en: 'NULL', es: 'NULL', fr: 'NULL' })}
-
-
- {t('core.identificationData.dateOfBirth.label')}
-
-
- {t({ en: 'Min:', es: 'Mín.:', fr: 'Min :' })}
- {
- dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => {
- return {
- ...prevValue,
- min: event.target.valueAsDate
- };
- });
- }}
- />
-
-
- {t({ en: 'Max:', es: 'Máx.:', fr: 'Max :' })}
- {
- dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => {
- return {
- ...prevValue,
- max: event.target.valueAsDate
- };
- });
- }}
- />
-
- {
- dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => {
- return {
- ...prevValue,
- allowNull: checked
- };
- });
- }}
- onSelect={(e) => e.preventDefault()}
- >
- {t({ en: 'NULL', es: 'NULL', fr: 'NULL' })}
-
-
-
-
- {t({
- en: 'Subjects with records',
- es: 'Sujetos con registros',
- fr: 'Sujets avec enregistrements'
- })}
-
- e.preventDefault()}
- >
- {t({
- en: 'With records only',
- es: 'Solo con registros',
- fr: 'Avec enregistrements seulement'
- })}
-
-
-
-
- );
-};
-
-const Toggles: React.FC<{
- hasRecords: boolean;
- setHasRecords: (v: boolean) => void;
- table: TanstackTable.Table;
-}> = ({ hasRecords, setHasRecords, table }) => {
- const navigate = Route.useNavigate();
-
- const { t } = useTranslation();
-
- const download = useDownload();
- const addNotification = useNotificationsStore((store) => store.addNotification);
-
- const currentGroup = useAppStore((store) => store.currentGroup);
- const currentUser = useAppStore((store) => store.currentUser);
-
- const [isLookupOpen, setIsLookupOpen] = useState(false);
-
- const lookupSubject = async ({ id }: { id: string }) => {
- const response = await axios.get(`/v1/subjects/${id}`, {
- validateStatus: (status) => status === 200 || status === 404
- });
- if (response.status === 404) {
- addNotification({ message: t('core.notFound'), type: 'warning' });
- setIsLookupOpen(false);
- } else {
- addNotification({ type: 'success' });
- await navigate({ to: `./${response.data.id}/table` });
- }
- };
-
- const getExportRecords = async () => {
- const response = await axios.get('/v1/instrument-records/export', {
- meta: {
- disableDefaultTimeout: true
- },
- params: {
- groupId: currentGroup?.id
- },
- responseType: 'arraybuffer'
- });
- return unpack(new Uint8Array(response.data)) as InstrumentRecordsExport;
- };
-
- const handleExportSelection = (option: 'CSV' | 'Excel' | 'JSON') => {
- const baseFilename = `${currentUser!.username}_${new Date().toISOString()}`;
- addNotification({
- message: t({
- en: 'Exporting entries, please wait...',
- es: 'Exportando entradas, espere...',
- fr: 'Téléchargement des entrées, veuillez patienter...'
- }),
- type: 'info'
- });
- const waitTime = new Promise((resolve) => {
- setTimeout(resolve, 350);
- });
-
- getExportRecords()
- .then((data): any => {
- const listedSubjects = getListedSubjectIds(table);
-
- const filteredData = data.filter((dataEntry) => listedSubjects.has(dataEntry.subjectId));
-
- if (filteredData.length < 1) {
- throw Error(
- t({
- en: 'Export failed: No entries to export',
- es: 'Error al exportar: no hay entradas que exportar',
- fr: "Échec de l'exportation : aucune entrée à exporter"
- })
- );
- }
-
- switch (option) {
- case 'CSV':
- void download('README.txt', t('datahub.index.table.exportHelpText'));
- void download(`${baseFilename}.csv`, unparse(filteredData));
- break;
- case 'Excel':
- return downloadExcel(`${baseFilename}.xlsx`, filteredData);
- case 'JSON':
- return download(`${baseFilename}.json`, JSON.stringify(filteredData, null, 2));
- }
- })
- .then(() => {
- return waitTime;
- })
- .then(() => {
- addNotification({
- message: t({ en: 'Export successful', es: 'Exportación realizada correctamente', fr: 'Exportation réussie' }),
- type: 'success'
- });
- })
- .catch((err) => {
- console.error(err);
- if (err instanceof Error && err.message) {
- addNotification({
- message: err.message,
- type: 'error'
- });
- } else {
- addNotification({
- message: t({ en: 'Export failed', es: 'Error al exportar', fr: "Échec de l'exportation" }),
- type: 'error'
- });
- }
- });
- };
-
- return (
-
-
-
-
- {t({
- en: 'Subject Lookup',
- es: 'Buscar sujeto',
- fr: 'Trouver un client'
- })}
-
-
-
-
-
- {t('datahub.index.lookup.title')}
-
- void lookupSubject(data)} />
-
-
-
-
-
- );
-};
-
-const MasterDataTable: React.FC<{
- data: Subject[];
- onRowDoubleClick: (subject: Subject) => void;
- onSelect: (subject: Subject) => void;
-}> = ({ data, onRowDoubleClick, onSelect }) => {
- const { t } = useTranslation();
- const currentGroup = useAppStore((store) => store.currentGroup);
- const subjectIdDisplaySetting = currentGroup?.settings.subjectIdDisplayLength;
-
- const [hasRecords, setHasRecords] = useState(false);
- const [searchString, setSearchString] = useState('');
- const [highlightedRowId, setHighlightedRowId] = useState(null);
-
- const subjectsData = useSubjectsQuery({
- params: { groupId: currentGroup?.id, hasRecord: hasRecords || undefined }
- });
-
- const displayData = hasRecords ? subjectsData.data : data;
-
- const hasRecordsRef = useRef(hasRecords);
- hasRecordsRef.current = hasRecords;
-
- const TogglesWithFilter = useMemo(() => {
- const Component = (props: { table: TanstackTable.Table }) => (
-
- );
- Component.displayName = 'TogglesWithFilter';
- return Component;
- }, []);
-
- return (
-
- removeSubjectIdScope(subject.id),
- cell: (ctx) => {
- const subject = ctx.row.original;
- const value = (ctx.getValue() as string).slice(0, subjectIdDisplaySetting ?? 9);
- return (
-
- {value}
-
-
- );
- },
- filterFn: (row, id, filter: HasSearchStringFilter) => {
- const value = row.getValue(id);
- if (!value) {
- return false;
- }
- if (filter.searchString) {
- return (value as string).toLowerCase().includes(filter.searchString.toLowerCase());
- }
- return true;
- },
- header: t('datahub.index.table.subject'),
- id: 'subjectId'
- },
- {
- accessorKey: 'dateOfBirth',
- cell: (ctx) => {
- const value = ctx.getValue() as Date | null | undefined;
- return value ? toBasicISOString(value) : t({ en: 'NULL', es: 'NULL', fr: 'NULL' });
- },
- filterFn: (row, id, filter: DateFilter) => {
- const value = row.getValue(id);
- if (!value) {
- return filter.allowNull;
- } else if (filter.max && value > filter.max) {
- return false;
- } else if (filter.min && value < filter.min) {
- return false;
- }
- return true;
- },
- header: t('core.identificationData.dateOfBirth.label')
- },
- {
- accessorFn: (subject) => subject.sex ?? null,
- cell: (ctx) => {
- switch (ctx.getValue() as Sex) {
- case 'FEMALE':
- return t('core.identificationData.sex.female');
- case 'MALE':
- return t('core.identificationData.sex.male');
- default:
- return t({ en: 'NULL', es: 'NULL', fr: 'NULL' });
- }
- },
- filterFn: (row, id, filter: SexFilter) => {
- return filter.includes(row.getValue(id));
- },
- header: t('core.identificationData.sex.label'),
- id: 'sex'
- }
- ]}
- data={displayData}
- data-testid="master-data-table"
- initialState={{
- columnFilters: [
- {
- id: 'subjectId',
- value: {
- searchString
- } satisfies HasSearchStringFilter
- },
- {
- id: 'sex',
- value: ['MALE', 'FEMALE', null] satisfies SexFilter
- },
- {
- id: 'dateOfBirth',
- value: {
- allowNull: true,
- max: null,
- min: null
- } satisfies DateFilter
- }
- ]
- }}
- rowActions={[
- {
- label: t({ en: 'View', es: 'Ver', fr: 'Voir' }),
- onSelect
- }
- ]}
- togglesComponent={TogglesWithFilter}
- onRowClick={(subject) => setHighlightedRowId(subject.id)}
- onRowDoubleClick={onRowDoubleClick}
- onSearchChange={(value, table) => {
- setSearchString(value);
- const subjectIdColumn = table.getColumn('subjectId')!;
- subjectIdColumn.setFilterValue((prevValue: HasSearchStringFilter): HasSearchStringFilter => ({
- ...prevValue,
- searchString: value
- }));
- }}
- />
-
- );
-};
-
-const RouteComponent = () => {
- const currentGroup = useAppStore((store) => store.currentGroup);
-
- const { t } = useTranslation();
- const navigate = useNavigate();
-
- const { data } = useSubjectsQuery({ params: { groupId: currentGroup?.id } });
-
- return (
-
-
-
- {t('datahub.index.title')}
-
-
-
- {
- void navigate({ to: `./${subject.id}/table` });
- }}
- onSelect={(subject) => {
- void navigate({ to: `./${subject.id}/table` });
- }}
- />
-
-
- );
-};
+import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/datahub/')({
- component: RouteComponent,
- loader: async ({ context }) => {
- const { currentGroup } = useAppStore.getState();
- await context.queryClient.ensureQueryData(subjectsQueryOptions({ params: { groupId: currentGroup?.id } }));
+ beforeLoad: () => {
+ throw redirect({ to: '/datahub/subjects' });
}
});
diff --git a/apps/web/src/routes/_app/datahub/$subjectId/assignments.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx
similarity index 97%
rename from apps/web/src/routes/_app/datahub/$subjectId/assignments.tsx
rename to apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx
index 0d206ac06..bbc26503c 100644
--- a/apps/web/src/routes/_app/datahub/$subjectId/assignments.tsx
+++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx
@@ -180,10 +180,10 @@ const RouteComponent = () => {
);
};
-export const Route = createFileRoute('/_app/datahub/$subjectId/assignments')({
+export const Route = createFileRoute('/_app/datahub/subjects/$subjectId/assignments')({
beforeLoad: ({ params }) => {
if (!config.setup.isGatewayEnabled) {
- throw redirect({ params, to: '/datahub/$subjectId/table' });
+ throw redirect({ params, to: '/datahub/subjects/$subjectId/table' });
}
},
component: RouteComponent
diff --git a/apps/web/src/routes/_app/datahub/$subjectId/graph.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx
similarity index 98%
rename from apps/web/src/routes/_app/datahub/$subjectId/graph.tsx
rename to apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx
index 03565a2cf..3d92eb187 100644
--- a/apps/web/src/routes/_app/datahub/$subjectId/graph.tsx
+++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx
@@ -178,6 +178,6 @@ const RouteComponent = () => {
);
};
-export const Route = createFileRoute('/_app/datahub/$subjectId/graph')({
+export const Route = createFileRoute('/_app/datahub/subjects/$subjectId/graph')({
component: RouteComponent
});
diff --git a/apps/web/src/routes/_app/datahub/$subjectId/route.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/route.tsx
similarity index 68%
rename from apps/web/src/routes/_app/datahub/$subjectId/route.tsx
rename to apps/web/src/routes/_app/datahub/subjects/$subjectId/route.tsx
index c8189625b..0ca63c90d 100644
--- a/apps/web/src/routes/_app/datahub/$subjectId/route.tsx
+++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/route.tsx
@@ -2,39 +2,20 @@ import React from 'react';
import { Heading } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
-import { cn } from '@douglasneuroinformatics/libui/utils';
import { removeSubjectIdScope } from '@opendatacapture/subject-utils';
-import { createFileRoute, Link, Outlet, useLocation } from '@tanstack/react-router';
+import { createFileRoute, Outlet } from '@tanstack/react-router';
import { LoadingFallback } from '@/components/LoadingFallback';
import { PageHeader } from '@/components/PageHeader';
+import { TabLink } from '@/components/TabLink';
import { config } from '@/config';
import { useAppStore } from '@/store';
-const TabLink = ({ label, pathname, testId }: { label: string; pathname: string; testId?: string }) => {
- const location = useLocation();
- const isActive = location.pathname.startsWith(pathname);
- return (
-
- {label}
-
- );
-};
-
const RouteComponent = () => {
const params = Route.useParams();
const { t } = useTranslation('datahub');
const subjectId = params.subjectId;
- const basePathname = `/datahub/${subjectId}`;
+ const basePathname = `/datahub/subjects/${subjectId}`;
const subjectIdDisplaySetting = useAppStore((store) => store.currentGroup?.settings.subjectIdDisplayLength);
return (
@@ -71,6 +52,6 @@ const RouteComponent = () => {
);
};
-export const Route = createFileRoute('/_app/datahub/$subjectId')({
+export const Route = createFileRoute('/_app/datahub/subjects/$subjectId')({
component: RouteComponent
});
diff --git a/apps/web/src/routes/_app/datahub/$subjectId/table/$recordId.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/$recordId.tsx
similarity index 98%
rename from apps/web/src/routes/_app/datahub/$subjectId/table/$recordId.tsx
rename to apps/web/src/routes/_app/datahub/subjects/$subjectId/table/$recordId.tsx
index 960172426..4ca760311 100644
--- a/apps/web/src/routes/_app/datahub/$subjectId/table/$recordId.tsx
+++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/$recordId.tsx
@@ -148,7 +148,7 @@ const RouteComponent = () => {
);
};
-export const Route = createFileRoute('/_app/datahub/$subjectId/table/$recordId')({
+export const Route = createFileRoute('/_app/datahub/subjects/$subjectId/table/$recordId')({
component: RouteComponent,
loader: async ({ context, params }) => {
const record = await context.queryClient.ensureQueryData(
diff --git a/apps/web/src/routes/_app/datahub/$subjectId/table/index.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/index.tsx
similarity index 97%
rename from apps/web/src/routes/_app/datahub/$subjectId/table/index.tsx
rename to apps/web/src/routes/_app/datahub/subjects/$subjectId/table/index.tsx
index 1715c8872..77ecd6eb9 100644
--- a/apps/web/src/routes/_app/datahub/$subjectId/table/index.tsx
+++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/index.tsx
@@ -106,6 +106,6 @@ const RouteComponent = () => {
);
};
-export const Route = createFileRoute('/_app/datahub/$subjectId/table/')({
+export const Route = createFileRoute('/_app/datahub/subjects/$subjectId/table/')({
component: RouteComponent
});
diff --git a/apps/web/src/routes/_app/datahub/subjects/index.tsx b/apps/web/src/routes/_app/datahub/subjects/index.tsx
new file mode 100644
index 000000000..7e5e3e68f
--- /dev/null
+++ b/apps/web/src/routes/_app/datahub/subjects/index.tsx
@@ -0,0 +1,705 @@
+import React, { useMemo, useRef, useState } from 'react';
+
+import { toBasicISOString } from '@douglasneuroinformatics/libjs';
+import {
+ ActionDropdown,
+ Button,
+ DataTable,
+ Dialog,
+ DropdownMenu,
+ Heading
+} from '@douglasneuroinformatics/libui/components';
+import type { TanstackTable } from '@douglasneuroinformatics/libui/components';
+import { useDownload, useNotificationsStore, useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks';
+import type { InstrumentRecordsExport } from '@opendatacapture/schemas/instrument-records';
+import type { Sex, Subject } from '@opendatacapture/schemas/subject';
+import { removeSubjectIdScope } from '@opendatacapture/subject-utils';
+import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import axios from 'axios';
+import { ChevronDownIcon, UserSearchIcon } from 'lucide-react';
+import { unpack } from 'msgpackr/unpack';
+import { unparse } from 'papaparse';
+
+import { ColorTagCell } from '@/components/ColorTagCell';
+import { IdentificationForm } from '@/components/IdentificationForm';
+import { PageHeader } from '@/components/PageHeader';
+import { SearchSubmitGuard } from '@/components/SearchSubmitGuard';
+import { SortableHeader } from '@/components/SortableHeader';
+import { subjectRecordSummaryQueryOptions, useSubjectRecordSummaryQuery } from '@/hooks/useSubjectRecordSummaryQuery';
+import { subjectsQueryOptions, useSubjectsQuery } from '@/hooks/useSubjectsQuery';
+import { useAppStore } from '@/store';
+import { downloadExcel } from '@/utils/excel';
+import { getListedSubjectIds } from '@/utils/table';
+import { getSexColor } from '@/utils/tag-colors';
+
+/** Shown where a subject has never had a record collected */
+const EMPTY_CELL = '—';
+
+type DateFilter = {
+ allowNull: boolean;
+ max: Date | null;
+ min: Date | null;
+};
+
+type SexFilter = (null | Sex)[];
+
+type HasSearchStringFilter = {
+ searchString: string;
+};
+
+/** A named window over the collection date, or an explicit one the user typed */
+type CollectedPreset =
+ | 'all'
+ | 'custom'
+ | 'pastMonth'
+ | 'pastSixMonths'
+ | 'pastThreeMonths'
+ | 'pastTwoYears'
+ | 'pastWeek'
+ | 'pastYear';
+
+type CollectedFilter = {
+ max: Date | null;
+ min: Date | null;
+ preset: CollectedPreset;
+};
+
+/** The earliest date a preset admits, or null for no lower bound */
+function presetMinDate(preset: CollectedPreset): Date | null {
+ const now = new Date();
+ const monthsAgo = (count: number) => new Date(new Date(now).setMonth(now.getMonth() - count));
+ const yearsAgo = (count: number) => new Date(new Date(now).setFullYear(now.getFullYear() - count));
+ switch (preset) {
+ case 'pastMonth':
+ return monthsAgo(1);
+ case 'pastSixMonths':
+ return monthsAgo(6);
+ case 'pastThreeMonths':
+ return monthsAgo(3);
+ case 'pastTwoYears':
+ return yearsAgo(2);
+ case 'pastWeek':
+ return new Date(new Date(now).setDate(now.getDate() - 7));
+ case 'pastYear':
+ return yearsAgo(1);
+ default:
+ return null;
+ }
+}
+
+const Filters: React.FC<{
+ minRecords: string;
+ setMinRecords: (v: string) => void;
+ table: TanstackTable.Table;
+}> = ({ minRecords, setMinRecords, table }) => {
+ const { t } = useTranslation();
+
+ const [isOpen, setIsOpen] = useState(false);
+
+ const columns = table.getAllColumns();
+
+ const collectedColumn = columns.find((column) => column.id === 'lastCollectedAt')!;
+ const collectedFilter = collectedColumn.getFilterValue() as CollectedFilter;
+
+ const dobColumn = columns.find((column) => column.id === 'dateOfBirth')!;
+ const dobFilter = dobColumn.getFilterValue() as DateFilter;
+
+ const sexColumn = columns.find((column) => column.id === 'sex')!;
+ const sexFilter = sexColumn.getFilterValue() as SexFilter;
+
+ return (
+
+
+
+ {t('common.filters')}
+
+
+
+
+ {t('core.identificationData.sex.label')}
+
+ {
+ sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => {
+ if (checked) {
+ return [...prevValue, 'MALE'];
+ }
+ return prevValue.filter((item) => item !== 'MALE');
+ });
+ }}
+ onSelect={(e) => e.preventDefault()}
+ >
+ {t('core.identificationData.sex.male')}
+
+ {
+ sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => {
+ if (checked) {
+ return [...prevValue, 'FEMALE'];
+ }
+ return prevValue.filter((item) => item !== 'FEMALE');
+ });
+ }}
+ onSelect={(e) => e.preventDefault()}
+ >
+ {t('core.identificationData.sex.female')}
+
+ {
+ sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => {
+ if (checked) {
+ return [...prevValue, null];
+ }
+ return prevValue.filter((item) => item !== null);
+ });
+ }}
+ onSelect={(e) => e.preventDefault()}
+ >
+ {t({ en: 'NULL', es: 'NULL', fr: 'NULL' })}
+
+
+ {t('core.identificationData.dateOfBirth.label')}
+
+
+ {t({ en: 'Min:', es: 'Mín.:', fr: 'Min :' })}
+ {
+ dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => {
+ return {
+ ...prevValue,
+ min: event.target.valueAsDate
+ };
+ });
+ }}
+ />
+
+
+ {t({ en: 'Max:', es: 'Máx.:', fr: 'Max :' })}
+ {
+ dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => {
+ return {
+ ...prevValue,
+ max: event.target.valueAsDate
+ };
+ });
+ }}
+ />
+
+ {
+ dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => {
+ return {
+ ...prevValue,
+ allowNull: checked
+ };
+ });
+ }}
+ onSelect={(e) => e.preventDefault()}
+ >
+ {t({ en: 'NULL', es: 'NULL', fr: 'NULL' })}
+
+
+ {t({ en: 'Records', es: 'Registros', fr: 'Enregistrements' })}
+
+ {/* A minimum of one is what the old "with records only" checkbox meant, so the two
+ controls collapsed into this one rather than sitting beside each other saying
+ nearly the same thing. */}
+
+ {t({ en: 'At least:', es: 'Al menos:', fr: 'Au moins :' })}
+ setMinRecords(event.target.value)}
+ />
+
+
+ {t({ en: 'Collected:', es: 'Recopilado:', fr: 'Collecté :' })}
+ {/* A native select renders with the user agent's own white background, which reads as a
+ bright block in dark mode — so it carries the popover surface tokens explicitly, and
+ the options do too, since those are painted separately. */}
+ {
+ const preset = event.target.value as CollectedPreset;
+ collectedColumn.setFilterValue(
+ (prevValue: CollectedFilter): CollectedFilter => ({
+ // A preset owns both bounds; only `custom` leaves them to the user, and it
+ // starts from whatever window the preset had so the dates are a nudge, not blank.
+ max: preset === 'custom' ? prevValue.max : null,
+ min:
+ preset === 'custom' ? (prevValue.min ?? presetMinDate(prevValue.preset)) : presetMinDate(preset),
+ preset
+ })
+ );
+ }}
+ >
+ {t({ en: 'Any time', es: 'Cualquier fecha', fr: 'Toute période' })}
+ {t({ en: 'This week', es: 'Esta semana', fr: 'Cette semaine' })}
+ {t({ en: 'This month', es: 'Este mes', fr: 'Ce mois-ci' })}
+ {t({ en: '3 months', es: '3 meses', fr: '3 mois' })}
+ {t({ en: '6 months', es: '6 meses', fr: '6 mois' })}
+ {t({ en: 'This year', es: 'Este año', fr: 'Cette année' })}
+ {t({ en: '2 years', es: '2 años', fr: '2 ans' })}
+ {t({ en: 'Custom', es: 'Personalizado', fr: 'Personnalisé' })}
+
+
+ {collectedFilter.preset === 'custom' && (
+
+
+ {t({ en: 'Min:', es: 'Mín.:', fr: 'Min :' })}
+ {
+ collectedColumn.setFilterValue(
+ (prevValue: CollectedFilter): CollectedFilter => ({ ...prevValue, min: event.target.valueAsDate })
+ );
+ }}
+ />
+
+
+ {t({ en: 'Max:', es: 'Máx.:', fr: 'Max :' })}
+ {
+ collectedColumn.setFilterValue(
+ (prevValue: CollectedFilter): CollectedFilter => ({ ...prevValue, max: event.target.valueAsDate })
+ );
+ }}
+ />
+
+
+ )}
+
+
+
+ );
+};
+
+const Toggles: React.FC<{
+ minRecords: string;
+ setMinRecords: (v: string) => void;
+ table: TanstackTable.Table;
+}> = ({ minRecords, setMinRecords, table }) => {
+ const navigate = Route.useNavigate();
+
+ const { t } = useTranslation();
+
+ const download = useDownload();
+ const addNotification = useNotificationsStore((store) => store.addNotification);
+
+ const currentGroup = useAppStore((store) => store.currentGroup);
+ const currentUser = useAppStore((store) => store.currentUser);
+
+ const [isLookupOpen, setIsLookupOpen] = useState(false);
+
+ const lookupSubject = async ({ id }: { id: string }) => {
+ const response = await axios.get(`/v1/subjects/${id}`, {
+ validateStatus: (status) => status === 200 || status === 404
+ });
+ if (response.status === 404) {
+ addNotification({ message: t('core.notFound'), type: 'warning' });
+ setIsLookupOpen(false);
+ } else {
+ addNotification({ type: 'success' });
+ await navigate({ to: `./${response.data.id}/table` });
+ }
+ };
+
+ const getExportRecords = async () => {
+ const response = await axios.get('/v1/instrument-records/export', {
+ meta: {
+ disableDefaultTimeout: true
+ },
+ params: {
+ groupId: currentGroup?.id
+ },
+ responseType: 'arraybuffer'
+ });
+ return unpack(new Uint8Array(response.data)) as InstrumentRecordsExport;
+ };
+
+ const handleExportSelection = (option: 'CSV' | 'Excel' | 'JSON') => {
+ const baseFilename = `${currentUser!.username}_${new Date().toISOString()}`;
+ addNotification({
+ message: t({
+ en: 'Exporting entries, please wait...',
+ es: 'Exportando entradas, espere...',
+ fr: 'Téléchargement des entrées, veuillez patienter...'
+ }),
+ type: 'info'
+ });
+ const waitTime = new Promise((resolve) => {
+ setTimeout(resolve, 350);
+ });
+
+ getExportRecords()
+ .then((data): any => {
+ const listedSubjects = getListedSubjectIds(table);
+
+ const filteredData = data.filter((dataEntry) => listedSubjects.has(dataEntry.subjectId));
+
+ if (filteredData.length < 1) {
+ throw Error(
+ t({
+ en: 'Export failed: No entries to export',
+ es: 'Error al exportar: no hay entradas que exportar',
+ fr: "Échec de l'exportation : aucune entrée à exporter"
+ })
+ );
+ }
+
+ switch (option) {
+ case 'CSV':
+ void download('README.txt', t('datahub.index.table.exportHelpText'));
+ void download(`${baseFilename}.csv`, unparse(filteredData));
+ break;
+ case 'Excel':
+ return downloadExcel(`${baseFilename}.xlsx`, filteredData);
+ case 'JSON':
+ return download(`${baseFilename}.json`, JSON.stringify(filteredData, null, 2));
+ }
+ })
+ .then(() => {
+ return waitTime;
+ })
+ .then(() => {
+ addNotification({
+ message: t({ en: 'Export successful', es: 'Exportación realizada correctamente', fr: 'Exportation réussie' }),
+ type: 'success'
+ });
+ })
+ .catch((err) => {
+ console.error(err);
+ if (err instanceof Error && err.message) {
+ addNotification({
+ message: err.message,
+ type: 'error'
+ });
+ } else {
+ addNotification({
+ message: t({ en: 'Export failed', es: 'Error al exportar', fr: "Échec de l'exportation" }),
+ type: 'error'
+ });
+ }
+ });
+ };
+
+ return (
+
+
+
+
+ {t({
+ en: 'Subject Lookup',
+ es: 'Buscar sujeto',
+ fr: 'Trouver un client'
+ })}
+
+
+
+
+
+ {t('datahub.index.lookup.title')}
+
+ void lookupSubject(data)} />
+
+
+
+
+
+ );
+};
+
+const MasterDataTable: React.FC<{
+ data: Subject[];
+ onRowDoubleClick: (subject: Subject) => void;
+ onSelect: (subject: Subject) => void;
+}> = ({ data, onRowDoubleClick, onSelect }) => {
+ const { t } = useTranslation();
+ const [theme] = useTheme();
+ const currentGroup = useAppStore((store) => store.currentGroup);
+ const subjectIdDisplaySetting = currentGroup?.settings.subjectIdDisplayLength;
+
+ const [minRecords, setMinRecords] = useState('');
+ // An empty field means no minimum, which is why the parse falls back to zero rather than NaN.
+ const minRecordCount = Number.parseInt(minRecords, 10) || 0;
+ const [searchString, setSearchString] = useState('');
+ const [highlightedRowId, setHighlightedRowId] = useState(null);
+
+ const summaryQuery = useSubjectRecordSummaryQuery({ params: { groupId: currentGroup?.id } });
+ const summaries = useMemo(
+ () => new Map(summaryQuery.data.map((summary) => [summary.subjectId, summary])),
+ [summaryQuery.data]
+ );
+
+ // Filtered here rather than through the server's `hasRecord` parameter: the counts are already
+ // loaded for the column, so re-querying bought nothing and made the control lag a keystroke
+ // behind — `useSuspenseQuery` suspends on every parameter change, leaving the stale set on screen.
+ const displayData = useMemo(() => {
+ if (minRecordCount <= 0) {
+ return data;
+ }
+ return data.filter((subject) => (summaries.get(subject.id)?.recordCount ?? 0) >= minRecordCount);
+ }, [data, minRecordCount, summaries]);
+
+ const minRecordsRef = useRef(minRecords);
+ minRecordsRef.current = minRecords;
+
+ const TogglesWithFilter = useMemo(() => {
+ const Component = (props: { table: TanstackTable.Table }) => (
+
+ );
+ Component.displayName = 'TogglesWithFilter';
+ return Component;
+ }, []);
+
+ return (
+
+ removeSubjectIdScope(subject.id),
+ cell: (ctx) => {
+ const subject = ctx.row.original;
+ const value = (ctx.getValue() as string).slice(0, subjectIdDisplaySetting ?? 9);
+ return (
+
+ {value}
+
+
+ );
+ },
+ filterFn: (row, id, filter: HasSearchStringFilter) => {
+ const value = row.getValue(id);
+ if (!value) {
+ return false;
+ }
+ if (filter.searchString) {
+ return (value as string).toLowerCase().includes(filter.searchString.toLowerCase());
+ }
+ return true;
+ },
+ header: ({ column }) => ,
+ id: 'subjectId',
+ // Only a pinned column keeps its own width — `calculateColumnSizing` gives every
+ // unpinned one an equal share of what is left. Pinning the identifier is what widens it
+ // and narrows the date, sex and count columns beside it.
+ size: 260
+ },
+ {
+ accessorKey: 'dateOfBirth',
+ cell: (ctx) => {
+ const value = ctx.getValue() as Date | null | undefined;
+ return value ? toBasicISOString(value) : t({ en: 'NULL', es: 'NULL', fr: 'NULL' });
+ },
+ filterFn: (row, id, filter: DateFilter) => {
+ const value = row.getValue(id);
+ if (!value) {
+ return filter.allowNull;
+ } else if (filter.max && value > filter.max) {
+ return false;
+ } else if (filter.min && value < filter.min) {
+ return false;
+ }
+ return true;
+ },
+ header: ({ column }) => (
+
+ )
+ },
+ {
+ accessorFn: (subject) => subject.sex ?? null,
+ cell: (ctx) => {
+ const sex = ctx.getValue() as null | Sex;
+ const label =
+ sex === 'FEMALE'
+ ? t('core.identificationData.sex.female')
+ : sex === 'MALE'
+ ? t('core.identificationData.sex.male')
+ : t({ en: 'NULL', es: 'NULL', fr: 'NULL' });
+ return (
+
+ );
+ },
+ filterFn: (row, id, filter: SexFilter) => {
+ return filter.includes(row.getValue(id));
+ },
+ header: ({ column }) => ,
+ id: 'sex'
+ },
+ {
+ accessorFn: (subject) => summaries.get(subject.id)?.recordCount ?? 0,
+ header: ({ column }) => (
+
+ ),
+ id: 'recordCount'
+ },
+ {
+ accessorFn: (subject) => summaries.get(subject.id)?.lastCollectedAt ?? null,
+ cell: (ctx) => {
+ const value = ctx.getValue() as Date | null;
+ return value ? toBasicISOString(value) : EMPTY_CELL;
+ },
+ filterFn: (row, id, filter: CollectedFilter) => {
+ const value = row.getValue(id);
+ // A subject with nothing collected cannot satisfy a window, but must survive "any time".
+ if (!value) {
+ return filter.preset === 'all';
+ }
+ if (filter.min && value < filter.min) {
+ return false;
+ }
+ return !(filter.max && value > filter.max);
+ },
+ header: ({ column }) => (
+
+ ),
+ id: 'lastCollectedAt'
+ }
+ ]}
+ data={displayData}
+ data-testid="master-data-table"
+ initialState={{
+ columnPinning: { left: ['subjectId'] },
+ // eslint-disable-next-line perfectionist/sort-objects
+ columnFilters: [
+ {
+ id: 'subjectId',
+ value: {
+ searchString
+ } satisfies HasSearchStringFilter
+ },
+ {
+ id: 'sex',
+ value: ['MALE', 'FEMALE', null] satisfies SexFilter
+ },
+ {
+ id: 'dateOfBirth',
+ value: {
+ allowNull: true,
+ max: null,
+ min: null
+ } satisfies DateFilter
+ },
+ {
+ id: 'lastCollectedAt',
+ value: { max: null, min: null, preset: 'all' } satisfies CollectedFilter
+ }
+ ]
+ }}
+ rowActions={[
+ {
+ label: t({ en: 'View', es: 'Ver', fr: 'Voir' }),
+ onSelect
+ }
+ ]}
+ togglesComponent={TogglesWithFilter}
+ onRowClick={(subject) => setHighlightedRowId(subject.id)}
+ onRowDoubleClick={onRowDoubleClick}
+ onSearchChange={(value, table) => {
+ setSearchString(value);
+ const subjectIdColumn = table.getColumn('subjectId')!;
+ subjectIdColumn.setFilterValue(
+ (prevValue: HasSearchStringFilter): HasSearchStringFilter => ({
+ ...prevValue,
+ searchString: value
+ })
+ );
+ }}
+ />
+
+ );
+};
+
+const RouteComponent = () => {
+ const currentGroup = useAppStore((store) => store.currentGroup);
+
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ const { data } = useSubjectsQuery({ params: { groupId: currentGroup?.id } });
+
+ return (
+
+
+
+ {t('datahub.index.title')}
+
+
+
+ {
+ void navigate({ to: `./${subject.id}/table` });
+ }}
+ onSelect={(subject) => {
+ void navigate({ to: `./${subject.id}/table` });
+ }}
+ />
+
+
+ );
+};
+
+export const Route = createFileRoute('/_app/datahub/subjects/')({
+ component: RouteComponent,
+ loader: async ({ context }) => {
+ const { currentGroup } = useAppStore.getState();
+ await Promise.all([
+ context.queryClient.ensureQueryData(subjectsQueryOptions({ params: { groupId: currentGroup?.id } })),
+ context.queryClient.ensureQueryData(subjectRecordSummaryQueryOptions({ params: { groupId: currentGroup?.id } }))
+ ]);
+ }
+});
diff --git a/apps/web/src/utils/tag-colors.ts b/apps/web/src/utils/tag-colors.ts
new file mode 100644
index 000000000..d983510ad
--- /dev/null
+++ b/apps/web/src/utils/tag-colors.ts
@@ -0,0 +1,29 @@
+type Mode = 'dark' | 'light';
+
+/** Grey, for a subject whose sex was never recorded */
+const NEUTRAL_MARK = {
+ dark: '#a3a3a3',
+ light: '#525252'
+} as const;
+
+/**
+ * Baby pink for female, baby blue for male.
+ *
+ * One value serves both modes, because a pastel reads well against the dark surface as-is. Against
+ * the light surface both sit near 1.6:1, far under the 3:1 a mark would normally need — which is why
+ * `ColorTagCell` rings the dot, and why the label is always beside it rather than the colour
+ * carrying the value.
+ */
+const SEX_COLORS = {
+ FEMALE: '#f9a8d4',
+ MALE: '#89cff0'
+} as const;
+
+function getSexColor(sex: null | string, mode: Mode): string {
+ if (sex === 'FEMALE' || sex === 'MALE') {
+ return SEX_COLORS[sex];
+ }
+ return NEUTRAL_MARK[mode];
+}
+
+export { getSexColor };
diff --git a/testing/src/pages/_app/datahub/$subjectId/assignments.page.ts b/testing/src/pages/_app/datahub/subjects/$subjectId/assignments.page.ts
similarity index 91%
rename from testing/src/pages/_app/datahub/$subjectId/assignments.page.ts
rename to testing/src/pages/_app/datahub/subjects/$subjectId/assignments.page.ts
index 02a3fe9de..6a66e84c4 100644
--- a/testing/src/pages/_app/datahub/$subjectId/assignments.page.ts
+++ b/testing/src/pages/_app/datahub/subjects/$subjectId/assignments.page.ts
@@ -1,6 +1,6 @@
import type { Locator, Page } from '@playwright/test';
-import { AppPage } from '../../route.page';
+import { AppPage } from '../../../route.page';
export class SubjectAssignmentsPage extends AppPage {
readonly assignmentLink: Locator;
diff --git a/testing/src/pages/_app/datahub/$subjectId/graph.page.ts b/testing/src/pages/_app/datahub/subjects/$subjectId/graph.page.ts
similarity index 97%
rename from testing/src/pages/_app/datahub/$subjectId/graph.page.ts
rename to testing/src/pages/_app/datahub/subjects/$subjectId/graph.page.ts
index 2b1a8ee0c..ee215aaea 100644
--- a/testing/src/pages/_app/datahub/$subjectId/graph.page.ts
+++ b/testing/src/pages/_app/datahub/subjects/$subjectId/graph.page.ts
@@ -1,6 +1,6 @@
import type { Locator, Page } from '@playwright/test';
-import { AppPage } from '../../route.page';
+import { AppPage } from '../../../route.page';
export class SubjectGraphPage extends AppPage {
readonly chart: Locator;
diff --git a/testing/src/pages/_app/datahub/$subjectId/table/$recordId.page.ts b/testing/src/pages/_app/datahub/subjects/$subjectId/table/$recordId.page.ts
similarity index 91%
rename from testing/src/pages/_app/datahub/$subjectId/table/$recordId.page.ts
rename to testing/src/pages/_app/datahub/subjects/$subjectId/table/$recordId.page.ts
index e7cf02386..703708a74 100644
--- a/testing/src/pages/_app/datahub/$subjectId/table/$recordId.page.ts
+++ b/testing/src/pages/_app/datahub/subjects/$subjectId/table/$recordId.page.ts
@@ -1,6 +1,6 @@
import type { Locator, Page } from '@playwright/test';
-import { AppPage } from '../../../route.page';
+import { AppPage } from '../../../../route.page';
export class SubjectRecordDetailPage extends AppPage {
readonly instrumentGroupHeading: Locator;
diff --git a/testing/src/pages/_app/datahub/$subjectId/table/index.page.ts b/testing/src/pages/_app/datahub/subjects/$subjectId/table/index.page.ts
similarity index 75%
rename from testing/src/pages/_app/datahub/$subjectId/table/index.page.ts
rename to testing/src/pages/_app/datahub/subjects/$subjectId/table/index.page.ts
index 773f0b763..341c643be 100644
--- a/testing/src/pages/_app/datahub/$subjectId/table/index.page.ts
+++ b/testing/src/pages/_app/datahub/subjects/$subjectId/table/index.page.ts
@@ -1,6 +1,6 @@
import type { Page } from '@playwright/test';
-import { AppPage } from '../../../route.page';
+import { AppPage } from '../../../../route.page';
export class SubjectDataTablePage extends AppPage {
constructor(page: Page) {
diff --git a/testing/src/pages/_app/datahub/index.page.ts b/testing/src/pages/_app/datahub/subjects/index.page.ts
similarity index 62%
rename from testing/src/pages/_app/datahub/index.page.ts
rename to testing/src/pages/_app/datahub/subjects/index.page.ts
index 59e05b930..4918f0d93 100644
--- a/testing/src/pages/_app/datahub/index.page.ts
+++ b/testing/src/pages/_app/datahub/subjects/index.page.ts
@@ -1,11 +1,12 @@
import type { Download, Locator, Page } from '@playwright/test';
-import { AppPage } from '../route.page';
+import { AppPage } from '../../route.page';
export class DatahubPage extends AppPage {
+ readonly collectedPresetFilter: Locator;
readonly exportDropdown: Locator;
readonly filtersTrigger: Locator;
- readonly hasRecordsFilter: Locator;
+ readonly minRecordsFilter: Locator;
readonly pageHeader: Locator;
readonly rowActionsTrigger: Locator;
readonly rows: Locator;
@@ -16,7 +17,8 @@ export class DatahubPage extends AppPage {
super(page);
this.exportDropdown = page.getByTestId('datahub-export-dropdown');
this.filtersTrigger = page.getByTestId('datahub-filters-trigger');
- this.hasRecordsFilter = page.getByTestId('datahub-filter-has-records');
+ this.minRecordsFilter = page.getByTestId('datahub-filter-min-records');
+ this.collectedPresetFilter = page.getByTestId('datahub-filter-collected-preset');
this.pageHeader = page.getByTestId('page-header');
this.rowActionsTrigger = page.getByTestId('row-actions-trigger').first();
this.rows = page.getByTestId('data-table-body').getByTestId('data-table-row');
@@ -37,9 +39,29 @@ export class DatahubPage extends AppPage {
return started;
}
- /** Opens the filter menu and toggles "With records only", which refetches with `hasRecord=true`. */
- async toggleWithRecordsOnly() {
+ /**
+ * Opens the filter menu and requires at least `count` records. A minimum of one is what the old
+ * "with records only" checkbox meant; every value narrows on the per-subject counts already
+ * loaded for the column rather than refetching.
+ */
+ async requireAtLeastRecords(count: number) {
+ await this.filtersTrigger.click();
+ await this.minRecordsFilter.fill(String(count));
+ }
+
+ /** Opens the filter menu and picks a collection-date window. */
+ async selectCollectedWindow(
+ preset:
+ | 'all'
+ | 'custom'
+ | 'pastMonth'
+ | 'pastSixMonths'
+ | 'pastThreeMonths'
+ | 'pastTwoYears'
+ | 'pastWeek'
+ | 'pastYear'
+ ) {
await this.filtersTrigger.click();
- await this.hasRecordsFilter.click();
+ await this.collectedPresetFilter.selectOption(preset);
}
}
diff --git a/testing/src/specs/authorization.spec.ts b/testing/src/specs/authorization.spec.ts
index 399b42b0e..6ba9366c0 100644
--- a/testing/src/specs/authorization.spec.ts
+++ b/testing/src/specs/authorization.spec.ts
@@ -122,7 +122,12 @@ const PRIVILEGED_REQUESTS: PrivilegedRequest[] = [
/** Sidebar destinations a GROUP_MANAGER gets but a STANDARD user must not. */
// `/group/email-templates` is deliberately absent: its nav item only renders when mail is
// enabled instance-wide, which it is not for this suite. `mail.spec.ts` covers it there.
-const GROUP_MANAGER_ONLY_ROUTES = ['/dashboard', '/datahub', '/group/manage', '/session/remote-assignment'] as const;
+const GROUP_MANAGER_ONLY_ROUTES = [
+ '/dashboard',
+ '/datahub/subjects',
+ '/group/manage',
+ '/session/remote-assignment'
+] as const;
/** Sidebar destinations behind `can('manage', 'all')`, gated to ADMIN alone. */
const ADMIN_ONLY_ROUTES = [
@@ -145,6 +150,8 @@ test.describe('authorization', () => {
// Bulk remote assignments is enabled by default, which nests group links under a collapsible
// "Group Actions" menu — expand it so the child nav buttons become visible.
await dashboardPage.expandNavGroup('Group Actions');
+ // The datahub is likewise a collapsible group, over its subject and instrument views.
+ await dashboardPage.expandNavGroup('Data Hub');
for (const route of GROUP_MANAGER_ONLY_ROUTES) {
await expect(page.getByTestId(`nav-button-${route}`)).toBeVisible();
}
@@ -239,7 +246,7 @@ test.describe('authorization', () => {
});
});
- // The populated case -- a group manager reaching /datahub/$subjectId/table and picking an
+ // The populated case -- a group manager reaching /datahub/subjects/$subjectId/table and picking an
// instrument from the list -- is covered end to end by `instrument-completion.spec.ts`, which
// administers one first so the list has something in it. This case only pins the contract for a
// subject with no visible records: an empty list, not an error.
diff --git a/testing/src/specs/dashboard.spec.ts b/testing/src/specs/dashboard.spec.ts
index 5c41549fb..ede64d82b 100644
--- a/testing/src/specs/dashboard.spec.ts
+++ b/testing/src/specs/dashboard.spec.ts
@@ -22,7 +22,7 @@ test.describe('dashboard', () => {
test('should navigate to the data hub when the subjects statistic is clicked', async ({ getPageModel, page }) => {
const dashboardPage = await getPageModel('/dashboard');
await dashboardPage.statisticSubjects.click();
- await expect(page).toHaveURL('/datahub');
+ await expect(page).toHaveURL('/datahub/subjects');
});
test('should open a dialog listing users when the users statistic is clicked', async ({ getPageModel }) => {
diff --git a/testing/src/specs/datahub.spec.ts b/testing/src/specs/datahub.spec.ts
index e25a07795..a515fa343 100644
--- a/testing/src/specs/datahub.spec.ts
+++ b/testing/src/specs/datahub.spec.ts
@@ -1,6 +1,6 @@
import { readFile } from 'node:fs/promises';
-import { DatahubPage } from '../pages/_app/datahub/index.page';
+import { DatahubPage } from '../pages/_app/datahub/subjects/index.page';
import { HAPPINESS_RECORD, PHONE_VIEWPORT } from '../support/constants';
import { expect, test } from '../support/fixtures';
@@ -10,7 +10,7 @@ test.describe('data hub on a phone', () => {
test('should keep every table control within the width of the search bar, so none is pushed off screen', async ({
getPageModel
}) => {
- const datahubPage = await getPageModel('/datahub');
+ const datahubPage = await getPageModel('/datahub/subjects');
await expect(datahubPage.exportDropdown).toBeVisible();
const searchBarBox = (await datahubPage.searchBar.boundingBox())!;
@@ -36,7 +36,7 @@ test.describe('data hub', () => {
]);
await authenticateAs(credentials);
- await page.goto('/datahub');
+ await page.goto('/datahub/subjects');
await expect(page.getByTestId('data-table-row')).toHaveCount(1);
const downloadPromise = page.waitForEvent('download');
@@ -50,7 +50,7 @@ test.describe('data hub', () => {
});
test('should display the data hub header', async ({ getPageModel }) => {
- const datahubPage = await getPageModel('/datahub');
+ const datahubPage = await getPageModel('/datahub/subjects');
await expect(datahubPage.pageHeader).toBeVisible();
await expect(datahubPage.pageHeader).toContainText('Data Hub');
});
@@ -67,15 +67,15 @@ test.describe('data hub', () => {
await startSessionPage.submitForm();
await expect(startSessionPage.successMessage).toBeVisible();
- await page.locator('[data-testid^="nav-button-/datahub/"]').click();
- await page.waitForURL('**/datahub/**/table');
- const [, subjectId] = /\/datahub\/([^/]+)\/table/.exec(page.url()) ?? [];
+ await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click();
+ await page.waitForURL('**/datahub/subjects/**/table');
+ const [, subjectId] = /\/datahub\/subjects\/([^/]+)\/table/.exec(page.url()) ?? [];
if (!subjectId) {
throw new Error(`Failed to extract subjectId from URL: ${page.url()}`);
}
const displayedId = subjectId.slice(0, 9);
- const datahubPage = await getPageModel('/datahub');
+ const datahubPage = await getPageModel('/datahub/subjects');
await datahubPage.searchInput.fill(displayedId);
await expect(page.getByTestId('data-table-row').filter({ hasText: displayedId })).toBeVisible();
@@ -98,11 +98,11 @@ test.describe('data hub', () => {
await startSessionPage.submitForm();
await expect(startSessionPage.successMessage).toBeVisible();
- const datahubPage = await getPageModel('/datahub');
+ const datahubPage = await getPageModel('/datahub/subjects');
await datahubPage.searchInput.fill(customIdentifier);
await datahubPage.searchInput.press('Enter');
- await expect(page).toHaveURL(/\/datahub$/);
+ await expect(page).toHaveURL(/\/datahub\/subjects$/);
await expect(datahubPage.searchInput).toHaveValue(customIdentifier);
await expect(page.getByTestId('data-table-row')).toHaveCount(1);
await expect(page.getByTestId('data-table-row')).toContainText(customIdentifier.slice(0, 9));
@@ -120,25 +120,28 @@ test.describe('data hub', () => {
await startSessionPage.submitForm();
await expect(startSessionPage.successMessage).toBeVisible();
- await page.locator('[data-testid^="nav-button-/datahub/"]').click();
- await page.waitForURL('**/datahub/**/table');
- const [, subjectId] = /\/datahub\/([^/]+)\/table/.exec(page.url()) ?? [];
+ await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click();
+ await page.waitForURL('**/datahub/subjects/**/table');
+ const [, subjectId] = /\/datahub\/subjects\/([^/]+)\/table/.exec(page.url()) ?? [];
if (!subjectId) {
throw new Error(`Failed to extract subjectId from URL: ${page.url()}`);
}
- const datahubPage = await getPageModel('/datahub');
+ const datahubPage = await getPageModel('/datahub/subjects');
await datahubPage.searchInput.fill(subjectId.slice(0, 9));
await datahubPage.rowActionsTrigger.click();
await page.getByRole('menuitem', { name: 'View' }).click();
- await expect(page).toHaveURL(new RegExp(`/datahub/${subjectId}/table$`));
+ await expect(page).toHaveURL(new RegExp(`/datahub/subjects/${subjectId}/table$`));
// `subject-table` used to be the Table tab link, so selecting the record table by it silently
// got the tab instead (#1475). It is the table alone now, and the tab is `subject-table-tab`.
await expect(page.getByTestId('subject-table')).toHaveCount(1);
await expect(page.getByTestId('subject-table').getByTestId('data-table')).toHaveCount(1);
- await expect(page.getByTestId('subject-table-tab')).toHaveAttribute('data-nav-url', `/datahub/${subjectId}/table`);
+ await expect(page.getByTestId('subject-table-tab')).toHaveAttribute(
+ 'data-nav-url',
+ `/datahub/subjects/${subjectId}/table`
+ );
});
test('should navigate to a subject by custom identifier via the subject lookup dialog', async ({
@@ -154,7 +157,7 @@ test.describe('data hub', () => {
await startSessionPage.submitForm();
await expect(startSessionPage.successMessage).toBeVisible();
- await getPageModel('/datahub');
+ await getPageModel('/datahub/subjects');
await page.getByTestId('subject-lookup-search-button').click();
const identificationForm = page.getByTestId('identification-form');
@@ -162,7 +165,7 @@ test.describe('data hub', () => {
await identificationForm.locator('[name="id"]').fill(customIdentifier);
await identificationForm.getByRole('button', { name: 'Submit' }).click();
- await expect(page).toHaveURL(/\/datahub\/.+\/table$/);
+ await expect(page).toHaveURL(/\/datahub\/subjects\/.+\/table$/);
});
// The export endpoint returns every record in the group; which of them reach the file is decided
@@ -185,7 +188,7 @@ test.describe('data hub', () => {
);
const datahubPage = new DatahubPage(page);
- await datahubPage.goto('/datahub');
+ await datahubPage.goto('/datahub/subjects');
await expect(datahubPage.rows).toHaveCount(2);
await datahubPage.searchInput.fill(listed);
@@ -198,7 +201,7 @@ test.describe('data hub', () => {
expect([...new Set(payload.map((row) => row.subjectId))]).toStrictEqual([listed]);
});
- test('should list only subjects holding records once "with records only" is applied', async ({
+ test('should list only subjects holding records once a minimum record count is applied', async ({
api,
isolatedGroupManager,
page,
@@ -217,10 +220,10 @@ test.describe('data hub', () => {
]);
const datahubPage = new DatahubPage(page);
- await datahubPage.goto('/datahub');
+ await datahubPage.goto('/datahub/subjects');
await expect(datahubPage.rows).toHaveCount(3);
- await datahubPage.toggleWithRecordsOnly();
+ await datahubPage.requireAtLeastRecords(1);
await expect(datahubPage.rows).toHaveCount(1);
// The cell renders at most the id's first nine characters (the subjectIdDisplayLength default),
@@ -228,6 +231,38 @@ test.describe('data hub', () => {
await expect(datahubPage.rows).toContainText(withRecord.slice(0, 9));
});
+ // The counts and the date window are read from the per-subject summary, so a filter that narrows
+ // on them is the only thing proving that summary actually reached the table.
+ test('should narrow the list by a minimum record count and by a collection-date window', async ({
+ api,
+ isolatedGroupManager,
+ page,
+ uniqueId
+ }) => {
+ const group = await isolatedGroupManager();
+ const instrumentId = await api.findInstrumentIdByName('DNP_HAPPINESS_QUESTIONNAIRE');
+ const twice = `twice-${uniqueId}`;
+ const once = `once-${uniqueId}`;
+ await api.uploadRecords(group.id, instrumentId, [
+ { data: HAPPINESS_RECORD, date: new Date(), subjectId: twice },
+ { data: HAPPINESS_RECORD, date: new Date(), subjectId: twice },
+ { data: HAPPINESS_RECORD, date: new Date(), subjectId: once }
+ ]);
+
+ const datahubPage = new DatahubPage(page);
+ await datahubPage.goto('/datahub/subjects');
+ await expect(datahubPage.rows).toHaveCount(2);
+
+ // Two records exist for one subject only, so a minimum of two must leave exactly that subject.
+ await datahubPage.requireAtLeastRecords(2);
+ await expect(datahubPage.rows).toHaveCount(1);
+ await expect(datahubPage.rows).toContainText(twice.slice(0, 9));
+
+ // Everything was collected just now, so the tightest window keeps it and nothing is lost.
+ await datahubPage.selectCollectedWindow('pastMonth');
+ await expect(datahubPage.rows).toHaveCount(1);
+ });
+
// `GET /v1/subjects` is gated on `read Subject`, which a standard user holds, but resolving
// `hasRecord` reads instrument records, which they do not. The honest answer is an empty list.
test('should answer the with-records filter for a caller who may read no records', async ({
diff --git a/testing/src/specs/error-page.spec.ts b/testing/src/specs/error-page.spec.ts
index 9d1c93ea4..e505c9bc9 100644
--- a/testing/src/specs/error-page.spec.ts
+++ b/testing/src/specs/error-page.spec.ts
@@ -20,7 +20,7 @@ test.describe('error page', () => {
});
test('should offer the report for copying beside the download when a route fails', async ({ page }) => {
- await page.goto('/datahub');
+ await page.goto('/datahub/subjects');
const errorPage = page.getByTestId('error-page');
await expect(errorPage).toBeVisible();
await expect(page.getByTestId('download-error-report')).toContainText('Download Error Report');
@@ -32,7 +32,7 @@ test.describe('error page', () => {
page
}) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
- await page.goto('/datahub');
+ await page.goto('/datahub/subjects');
const copyButton = page.getByTestId('copy-error-report');
await copyButton.click();
await expect(copyButton).toContainText('Copied');
diff --git a/testing/src/specs/gateway-assignment.spec.ts b/testing/src/specs/gateway-assignment.spec.ts
index 3e8af58b8..8ae7dfd4e 100644
--- a/testing/src/specs/gateway-assignment.spec.ts
+++ b/testing/src/specs/gateway-assignment.spec.ts
@@ -122,10 +122,10 @@ test.describe('gateway remote assignment', () => {
await gatewayPage.close();
// Back on the clinician side the assignment should now read as complete.
- await page.locator('[data-testid^="nav-button-/datahub/"]').click();
- await page.waitForURL('**/datahub/**/table');
+ await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click();
+ await page.waitForURL('**/datahub/subjects/**/table');
await page.getByRole('link', { name: 'Assignments' }).click();
- await page.waitForURL('**/datahub/**/assignments');
+ await page.waitForURL('**/datahub/subjects/**/assignments');
// The completion posts back from the gateway asynchronously (it can still read "Outstanding"
// for a while on a slow runner), and assertion retries re-query the locator rather than
diff --git a/testing/src/specs/instrument-completion.spec.ts b/testing/src/specs/instrument-completion.spec.ts
index baf2978ce..ae1854a01 100644
--- a/testing/src/specs/instrument-completion.spec.ts
+++ b/testing/src/specs/instrument-completion.spec.ts
@@ -43,8 +43,8 @@ test.describe('instrument completion', () => {
// "View Current Subject" targets the active subject's record table directly, which is far more
// robust than matching a hashed row in the data hub list.
- await page.locator('[data-testid^="nav-button-/datahub/"]').click();
- await page.waitForURL('**/datahub/**/table');
+ await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click();
+ await page.waitForURL('**/datahub/subjects/**/table');
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: INSTRUMENT_TITLE }).click();
@@ -84,8 +84,8 @@ test.describe('instrument completion', () => {
await expect(instrumentPage.summaryHeading).toBeVisible();
- await page.locator('[data-testid^="nav-button-/datahub/"]').click();
- await page.waitForURL('**/datahub/**/table');
+ await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click();
+ await page.waitForURL('**/datahub/subjects/**/table');
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: INSTRUMENT_TITLE }).click();
@@ -174,8 +174,8 @@ test.describe('instrument completion', () => {
await expect(instrumentPage.summaryHeading).toBeVisible();
- await page.locator('[data-testid^="nav-button-/datahub/"]').click();
- await page.waitForURL('**/datahub/**/table');
+ await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click();
+ await page.waitForURL('**/datahub/subjects/**/table');
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: DEMOGRAPHICS_INSTRUMENT_TITLE }).click();
@@ -213,8 +213,8 @@ test.describe('instrument completion', () => {
await expect(instrumentPage.summaryHeading).toBeVisible();
}
- await page.locator('[data-testid^="nav-button-/datahub/"]').click();
- await page.waitForURL('**/datahub/**/table');
+ await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click();
+ await page.waitForURL('**/datahub/subjects/**/table');
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: INSTRUMENT_TITLE }).click();
diff --git a/testing/src/specs/remote-assignment.spec.ts b/testing/src/specs/remote-assignment.spec.ts
index 46b7d3094..f63e5654d 100644
--- a/testing/src/specs/remote-assignment.spec.ts
+++ b/testing/src/specs/remote-assignment.spec.ts
@@ -112,11 +112,11 @@ test.describe('remote assignment', () => {
}) => {
await startSession(getPageModel, `Deep${uniqueId}`, `Subject${uniqueId}`, 'Male');
- await page.locator('[data-testid^="nav-button-/datahub/"]').click();
- await page.waitForURL('**/datahub/**/table');
- const subjectId = page.url().split('/datahub/')[1]!.split('/')[0]!;
+ await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click();
+ await page.waitForURL('**/datahub/subjects/**/table');
+ const subjectId = page.url().split('/datahub/subjects/')[1]!.split('/')[0]!;
- const assignmentsPage = await getPageModel('/datahub/$subjectId/assignments', { subjectId });
+ const assignmentsPage = await getPageModel('/datahub/subjects/$subjectId/assignments', { subjectId });
await expect(assignmentsPage.assignmentRows).toHaveCount(0);
});
@@ -150,10 +150,10 @@ test.describe('remote assignment', () => {
await expect(remoteAssignmentPage.urlInput).toBeVisible();
await remoteAssignmentPage.closeResultButton.click();
- await page.locator('[data-testid^="nav-button-/datahub/"]').click();
- await page.waitForURL('**/datahub/**/table');
+ await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click();
+ await page.waitForURL('**/datahub/subjects/**/table');
await page.getByRole('link', { name: 'Assignments' }).click();
- await page.waitForURL('**/datahub/**/assignments');
+ await page.waitForURL('**/datahub/subjects/**/assignments');
await page.getByRole('row').filter({ hasText: 'Happiness Questionnaire' }).click();
await page.getByRole('button', { name: 'Cancel' }).click();
diff --git a/testing/src/specs/series-instrument.spec.ts b/testing/src/specs/series-instrument.spec.ts
index e6fc431dc..543fa4d84 100644
--- a/testing/src/specs/series-instrument.spec.ts
+++ b/testing/src/specs/series-instrument.spec.ts
@@ -93,8 +93,8 @@ test.describe('series instrument', () => {
await instrumentPage.submit();
await expect(instrumentPage.seriesCompletionHeading).toBeVisible();
- await page.locator('[data-testid^="nav-button-/datahub/"]').click();
- await page.waitForURL('**/datahub/**/table');
+ await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click();
+ await page.waitForURL('**/datahub/subjects/**/table');
await page.getByRole('combobox').first().click();
await page.getByRole('option', { exact: true, name: ITEM_INSTRUMENT_TITLE }).click();
diff --git a/testing/src/specs/subject-detail.spec.ts b/testing/src/specs/subject-detail.spec.ts
index fb9e95211..06c15353b 100644
--- a/testing/src/specs/subject-detail.spec.ts
+++ b/testing/src/specs/subject-detail.spec.ts
@@ -1,8 +1,8 @@
import type { Page } from '@playwright/test';
-import { SubjectAssignmentsPage } from '../pages/_app/datahub/$subjectId/assignments.page';
-import { SubjectGraphPage } from '../pages/_app/datahub/$subjectId/graph.page';
-import { SubjectRecordDetailPage } from '../pages/_app/datahub/$subjectId/table/$recordId.page';
+import { SubjectAssignmentsPage } from '../pages/_app/datahub/subjects/$subjectId/assignments.page';
+import { SubjectGraphPage } from '../pages/_app/datahub/subjects/$subjectId/graph.page';
+import { SubjectRecordDetailPage } from '../pages/_app/datahub/subjects/$subjectId/table/$recordId.page';
import { RenderInstrumentPage } from '../pages/_app/instruments/render/$id.page';
import { RemoteAssignmentPage } from '../pages/_app/session/remote-assignment.page';
import { expect, test } from '../support/fixtures';
@@ -43,8 +43,10 @@ async function seedSubjectWithRecord(getPageModel: GetPageModel, page: Page, nam
await instrumentPage.submit();
await expect(instrumentPage.summaryHeading).toBeVisible();
- await page.locator('[data-testid^="nav-button-/datahub/"]').click();
- await page.waitForURL('**/datahub/**/table');
+ // The datahub nav entry is a group over the subject and instrument views; only the current
+ // subject's own link carries a path segment after `subjects`.
+ await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click();
+ await page.waitForURL('**/datahub/subjects/**/table');
}
test.describe('subject detail', () => {
@@ -93,8 +95,8 @@ test.describe('subject detail', () => {
// The result slider is a modal sheet, so it blocks clicks on the sidebar underneath until dismissed.
await page.keyboard.press('Escape');
- await page.locator('[data-testid^="nav-button-/datahub/"]').click();
- await page.waitForURL('**/datahub/**/table');
+ await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click();
+ await page.waitForURL('**/datahub/subjects/**/table');
await page.getByRole('link', { name: 'Assignments' }).click();
await page.waitForURL('**/assignments');
diff --git a/testing/src/specs/upload.spec.ts b/testing/src/specs/upload.spec.ts
index 94a04dcfc..acef322ad 100644
--- a/testing/src/specs/upload.spec.ts
+++ b/testing/src/specs/upload.spec.ts
@@ -32,7 +32,7 @@ test.describe('upload', () => {
await expect(page.getByText('Success')).toBeVisible();
// The subject list truncates the displayed id to 9 characters by default, so match on the prefix.
- const datahubPage = await getPageModel('/datahub');
+ const datahubPage = await getPageModel('/datahub/subjects');
await datahubPage.searchInput.fill(subjectId);
await expect(page.getByTestId('data-table-row').filter({ hasText: subjectId.slice(0, 9) })).toBeVisible();
});
diff --git a/testing/src/support/fixtures.ts b/testing/src/support/fixtures.ts
index a995c353f..1866ce5ec 100644
--- a/testing/src/support/fixtures.ts
+++ b/testing/src/support/fixtures.ts
@@ -16,11 +16,11 @@ import { AdminSettingsPage } from '../pages/_app/admin/settings.page';
import { AdminUserPage } from '../pages/_app/admin/users/$userId.page';
import { ContactPage } from '../pages/_app/contact.page';
import { DashboardPage } from '../pages/_app/dashboard.page';
-import { SubjectAssignmentsPage } from '../pages/_app/datahub/$subjectId/assignments.page';
-import { SubjectGraphPage } from '../pages/_app/datahub/$subjectId/graph.page';
-import { SubjectRecordDetailPage } from '../pages/_app/datahub/$subjectId/table/$recordId.page';
-import { SubjectDataTablePage } from '../pages/_app/datahub/$subjectId/table/index.page';
-import { DatahubPage } from '../pages/_app/datahub/index.page';
+import { SubjectAssignmentsPage } from '../pages/_app/datahub/subjects/$subjectId/assignments.page';
+import { SubjectGraphPage } from '../pages/_app/datahub/subjects/$subjectId/graph.page';
+import { SubjectRecordDetailPage } from '../pages/_app/datahub/subjects/$subjectId/table/$recordId.page';
+import { SubjectDataTablePage } from '../pages/_app/datahub/subjects/$subjectId/table/index.page';
+import { DatahubPage } from '../pages/_app/datahub/subjects/index.page';
import { GroupEmailTemplatesPage } from '../pages/_app/group/email-templates.page';
import { GroupManagePage } from '../pages/_app/group/manage.page';
import { RemoteAssignmentsPage } from '../pages/_app/group/remote-assignments.page';
@@ -53,11 +53,11 @@ const pageModels = {
'/auth/reset-password': ResetPasswordPage,
'/contact': ContactPage,
'/dashboard': DashboardPage,
- '/datahub': DatahubPage,
- '/datahub/$subjectId/assignments': SubjectAssignmentsPage,
- '/datahub/$subjectId/graph': SubjectGraphPage,
- '/datahub/$subjectId/table': SubjectDataTablePage,
- '/datahub/$subjectId/table/$recordId': SubjectRecordDetailPage,
+ '/datahub/subjects': DatahubPage,
+ '/datahub/subjects/$subjectId/assignments': SubjectAssignmentsPage,
+ '/datahub/subjects/$subjectId/graph': SubjectGraphPage,
+ '/datahub/subjects/$subjectId/table': SubjectDataTablePage,
+ '/datahub/subjects/$subjectId/table/$recordId': SubjectRecordDetailPage,
'/group/email-templates': GroupEmailTemplatesPage,
'/group/manage': GroupManagePage,
'/group/remote-assignments': RemoteAssignmentsPage,
From 5d99a3f6841fe1dce25266927bc40bc0a66cf070 Mon Sep 17 00:00:00 2001
From: thomasbeaudry
Date: Sat, 3 Oct 2026 11:06:22 -0400
Subject: [PATCH 3/6] refactor(web): fold the duplicated sortable headers into
the shared one
`SourceStep` and `DeleteRemoteAssignments` each carried a verbatim copy of the
same sortable column header, differing only in the row type it was written
against. Now that the datahub needs a third, the component is generic and lives
on its own, so the two copies become imports.
Co-Authored-By: Claude Opus 5
---
.../BulkRemoteAssignmentWizard/SourceStep.tsx | 23 +------------------
.../DeleteRemoteAssignments.tsx | 20 ++--------------
2 files changed, 3 insertions(+), 40 deletions(-)
diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx
index 626546b57..2901ca16f 100644
--- a/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx
+++ b/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx
@@ -2,14 +2,12 @@ import React, { useState } from 'react';
import { toBasicISOString } from '@douglasneuroinformatics/libjs';
import { Button, Checkbox, DataTable, FileDropzone, Tabs, TextArea } from '@douglasneuroinformatics/libui/components';
-import type { TanstackTable } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
-import { cn } from '@douglasneuroinformatics/libui/utils';
import { BULK_ASSIGNMENT_MAX_SUBJECTS } from '@opendatacapture/schemas/assignment';
import type { Subject } from '@opendatacapture/schemas/subject';
import { removeSubjectIdScope } from '@opendatacapture/subject-utils';
-import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react';
+import { SortableHeader } from '@/components/SortableHeader';
import {
ACCEPTED_FILE_EXTENSIONS,
assertFileSize,
@@ -34,25 +32,6 @@ type PickerRow = {
subject: string;
};
-/**
- * A clickable column label. `DataTableHead` renders whatever the column supplies, so the sort
- * affordance lives here rather than coming from the table.
- */
-const SortableHeader = ({ column, label }: { column: TanstackTable.Column; label: string }) => {
- const sorted = column.getIsSorted();
- const Icon = sorted === 'asc' ? ChevronUpIcon : sorted === 'desc' ? ChevronDownIcon : ChevronsUpDownIcon;
- return (
- column.toggleSorting()}
- >
- {label}
-
-
- );
-};
-
type SourceStepProps = {
onBack?: () => void;
onParsed: (parsed: BulkParseResult) => void;
diff --git a/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx b/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx
index 363d4e4d5..4fa1b13fb 100644
--- a/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx
+++ b/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx
@@ -2,14 +2,13 @@ import React, { useState } from 'react';
import { toBasicISOString } from '@douglasneuroinformatics/libjs';
import { Button, Card, Checkbox, DataTable, Dialog } from '@douglasneuroinformatics/libui/components';
-import type { TanstackTable } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
-import { cn } from '@douglasneuroinformatics/libui/utils';
import type { Assignment } from '@opendatacapture/schemas/assignment';
import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument';
import { removeSubjectIdScope } from '@opendatacapture/subject-utils';
-import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon, CircleAlertIcon, CircleDotIcon } from 'lucide-react';
+import { CircleAlertIcon, CircleDotIcon } from 'lucide-react';
+import { SortableHeader } from '@/components/SortableHeader';
import { useAssignmentsQuery } from '@/hooks/useAssignmentsQuery';
import { useDeleteBulkAssignmentsMutation } from '@/hooks/useDeleteBulkAssignmentsMutation';
import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery';
@@ -23,21 +22,6 @@ type AssignmentRow = {
subject: string;
};
-const SortableHeader = ({ column, label }: { column: TanstackTable.Column; label: string }) => {
- const sorted = column.getIsSorted();
- const Icon = sorted === 'asc' ? ChevronUpIcon : sorted === 'desc' ? ChevronDownIcon : ChevronsUpDownIcon;
- return (
- column.toggleSorting()}
- >
- {label}
-
-
- );
-};
-
const StatusCell = ({ status }: { status: 'EXPIRED' | 'OUTSTANDING' }) => {
const { t } = useTranslation();
if (status === 'OUTSTANDING') {
From 2d22f68558f1ac31427df3a4393e34e65b8ecc6c Mon Sep 17 00:00:00 2001
From: thomasbeaudry
Date: Mon, 5 Oct 2026 12:26:38 -0400
Subject: [PATCH 4/6] fix(web): reconcile the subject hub with main's
phone-layout and admin-instruments changes
The rebase onto main needed four reconciliations that could not be
resolved mechanically:
- route-tree.ts: add the /datahub/subjects/ nesting to main's version,
which gained /admin/instruments and a prettier reformat
- datahub-table-controls.test.tsx: point the import at the subjects
route and mock useSubjectRecordSummaryQuery
- subjects/index.tsx: adopt main's flex-wrap / grow phone-layout classes
- subjects/index.page.ts: check data-state before clicking the filter
trigger, so two filter methods in sequence do not toggle it closed
Co-Authored-By: Claude Opus 4.6
---
.../datahub-subject-assignments.test.tsx | 2 +-
.../__tests__/datahub-subject-graph.test.tsx | 2 +-
.../__tests__/datahub-subject-layout.test.tsx | 2 +-
.../__tests__/datahub-subject-record.test.tsx | 2 +-
.../__tests__/datahub-subject-table.test.tsx | 2 +-
.../__tests__/datahub-table-controls.test.tsx | 52 +++-
apps/web/src/route-tree.ts | 231 ++++++++++--------
.../routes/_app/datahub/subjects/index.tsx | 68 +++---
.../pages/_app/datahub/subjects/index.page.ts | 19 +-
9 files changed, 214 insertions(+), 166 deletions(-)
diff --git a/apps/web/src/__tests__/datahub-subject-assignments.test.tsx b/apps/web/src/__tests__/datahub-subject-assignments.test.tsx
index 2fdbf2c24..39210b6e1 100644
--- a/apps/web/src/__tests__/datahub-subject-assignments.test.tsx
+++ b/apps/web/src/__tests__/datahub-subject-assignments.test.tsx
@@ -4,7 +4,7 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/rea
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AssignmentEmailFormProps } from '@/components/AssignmentEmailForm';
-import { Route } from '@/routes/_app/datahub/$subjectId/assignments';
+import { Route } from '@/routes/_app/datahub/subjects/$subjectId/assignments';
import '@/services/i18n';
diff --git a/apps/web/src/__tests__/datahub-subject-graph.test.tsx b/apps/web/src/__tests__/datahub-subject-graph.test.tsx
index cbf0c6b4a..d3eeb01c5 100644
--- a/apps/web/src/__tests__/datahub-subject-graph.test.tsx
+++ b/apps/web/src/__tests__/datahub-subject-graph.test.tsx
@@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { LineGraphLine } from '@/components/LineGraph';
-import { Route } from '@/routes/_app/datahub/$subjectId/graph';
+import { Route } from '@/routes/_app/datahub/subjects/$subjectId/graph';
import '@/services/i18n';
diff --git a/apps/web/src/__tests__/datahub-subject-layout.test.tsx b/apps/web/src/__tests__/datahub-subject-layout.test.tsx
index 2d6f055ad..b89139757 100644
--- a/apps/web/src/__tests__/datahub-subject-layout.test.tsx
+++ b/apps/web/src/__tests__/datahub-subject-layout.test.tsx
@@ -3,7 +3,7 @@ import type { PropsWithChildren, ReactNode } from 'react';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { Route } from '@/routes/_app/datahub/$subjectId/route';
+import { Route } from '@/routes/_app/datahub/subjects/$subjectId/route';
import '@/services/i18n';
diff --git a/apps/web/src/__tests__/datahub-subject-record.test.tsx b/apps/web/src/__tests__/datahub-subject-record.test.tsx
index 4145c60ce..638e16e5e 100644
--- a/apps/web/src/__tests__/datahub-subject-record.test.tsx
+++ b/apps/web/src/__tests__/datahub-subject-record.test.tsx
@@ -10,7 +10,7 @@ import { QueryClient } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { Route } from '@/routes/_app/datahub/$subjectId/table/$recordId';
+import { Route } from '@/routes/_app/datahub/subjects/$subjectId/table/$recordId';
import '@/services/i18n';
diff --git a/apps/web/src/__tests__/datahub-subject-table.test.tsx b/apps/web/src/__tests__/datahub-subject-table.test.tsx
index dbe551ad8..e03edb878 100644
--- a/apps/web/src/__tests__/datahub-subject-table.test.tsx
+++ b/apps/web/src/__tests__/datahub-subject-table.test.tsx
@@ -1,7 +1,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { Route } from '@/routes/_app/datahub/$subjectId/table/index';
+import { Route } from '@/routes/_app/datahub/subjects/$subjectId/table/index';
import '@/services/i18n';
diff --git a/apps/web/src/__tests__/datahub-table-controls.test.tsx b/apps/web/src/__tests__/datahub-table-controls.test.tsx
index a93a02300..50491f057 100644
--- a/apps/web/src/__tests__/datahub-table-controls.test.tsx
+++ b/apps/web/src/__tests__/datahub-table-controls.test.tsx
@@ -1,10 +1,10 @@
-import type { InstrumentRecordsExport } from '@opendatacapture/schemas/instrument-records';
+import type { InstrumentRecordsExport, SubjectRecordSummary } from '@opendatacapture/schemas/instrument-records';
import type { Subject } from '@opendatacapture/schemas/subject';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { pack } from 'msgpackr/pack';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { Route } from '@/routes/_app/datahub/index';
+import { Route } from '@/routes/_app/datahub/subjects/index';
import '@/services/i18n';
@@ -29,9 +29,10 @@ const mocks = vi.hoisted(() => ({
currentGroup: null as null | StoreGroup,
currentUser: { username: 'jdoe' }
},
+ subjectRecordSummaryQueryOptions: vi.fn((options: object) => ({ options, queryKey: ['subject-record-summary'] })),
subjects: [] as Subject[],
subjectsQueryOptions: vi.fn((options: object) => ({ options, queryKey: ['subjects'] })),
- subjectsWithRecords: [] as Subject[]
+ summaries: [] as SubjectRecordSummary[]
}));
vi.mock('@tanstack/react-router', async (importOriginal) => ({
@@ -52,9 +53,11 @@ vi.mock('@/components/IdentificationForm', () => ({
}));
vi.mock('@/hooks/useSubjectsQuery', () => ({
subjectsQueryOptions: mocks.subjectsQueryOptions,
- useSubjectsQuery: ({ params }: { params: { hasRecord?: boolean } }) => ({
- data: params.hasRecord ? mocks.subjectsWithRecords : mocks.subjects
- })
+ useSubjectsQuery: () => ({ data: mocks.subjects })
+}));
+vi.mock('@/hooks/useSubjectRecordSummaryQuery', () => ({
+ subjectRecordSummaryQueryOptions: mocks.subjectRecordSummaryQueryOptions,
+ useSubjectRecordSummaryQuery: () => ({ data: mocks.summaries })
}));
vi.mock('@/store', () => ({
useAppStore: Object.assign((selector: (store: typeof mocks.store) => unknown) => selector(mocks.store), {
@@ -139,7 +142,8 @@ beforeEach(() => {
vi.clearAllMocks();
mocks.store.currentGroup = { id: 'group-1', settings: {} };
mocks.subjects = [ALICE, BOB, CAROL];
- mocks.subjectsWithRecords = [BOB];
+ // Only Bob has records, so a minimum of one narrows the list to him alone.
+ mocks.summaries = [{ lastCollectedAt: new Date('2026-01-01'), recordCount: 2, subjectId: BOB.id }];
vi.spyOn(Route, 'useNavigate').mockReturnValue(mocks.navigate);
});
@@ -159,6 +163,18 @@ describe('data hub route', () => {
expect(ensureQueryData).toHaveBeenCalledWith(mocks.subjectsQueryOptions.mock.results[0]!.value);
});
+ // The record counts and collection dates are columns of the first paint, so the summary is
+ // prefetched beside the subjects rather than suspending the table a second time.
+ it('should prefetch the per-subject record summary alongside the subjects', async () => {
+ const loader = Route.options.loader as (opts: {
+ context: { queryClient: { ensureQueryData: (options: unknown) => Promise } };
+ }) => Promise;
+ const ensureQueryData = vi.fn().mockResolvedValue([]);
+ await loader({ context: { queryClient: { ensureQueryData } } });
+ expect(mocks.subjectRecordSummaryQueryOptions).toHaveBeenCalledWith({ params: { groupId: 'group-1' } });
+ expect(ensureQueryData).toHaveBeenCalledWith(mocks.subjectRecordSummaryQueryOptions.mock.results[0]!.value);
+ });
+
it('should open the subject table when a row is double-clicked', () => {
renderDataHub();
fireEvent.doubleClick(screen.getAllByTestId('data-table-row')[1]!);
@@ -270,12 +286,30 @@ describe('data hub filters', () => {
expect(listedSubjects()).toEqual(['alice-123', 'bob']);
});
- it('should list only subjects with records when that option is checked', () => {
+ // A minimum of one is what the old "with records only" checkbox meant. The count comes from the
+ // per-subject summary already loaded for the column, so the filter never refetches.
+ it('should list only subjects holding at least the minimum number of records', () => {
renderDataHub();
openFilters();
- fireEvent.click(screen.getByTestId('datahub-filter-has-records'));
+ fireEvent.change(screen.getByTestId('datahub-filter-min-records'), { target: { value: '1' } });
expect(listedSubjects()).toEqual(['bob']);
});
+
+ it('should list every subject again when the minimum record count is cleared', () => {
+ renderDataHub();
+ openFilters();
+ const minRecords = screen.getByTestId('datahub-filter-min-records');
+ fireEvent.change(minRecords, { target: { value: '1' } });
+ fireEvent.change(minRecords, { target: { value: '' } });
+ expect(listedSubjects()).toEqual(['alice-123', 'bob', 'carol']);
+ });
+
+ it('should exclude a subject whose record count falls short of the minimum', () => {
+ renderDataHub();
+ openFilters();
+ fireEvent.change(screen.getByTestId('datahub-filter-min-records'), { target: { value: '3' } });
+ expect(listedSubjects()).toEqual([]);
+ });
});
describe('data hub subject lookup', () => {
diff --git a/apps/web/src/route-tree.ts b/apps/web/src/route-tree.ts
index cbadf9a5b..8500b2d8b 100644
--- a/apps/web/src/route-tree.ts
+++ b/apps/web/src/route-tree.ts
@@ -22,7 +22,6 @@ import { Route as AppAdminInstrumentsRouteImport } from './routes/_app/admin/ins
import { Route as AppAdminMailRouteImport } from './routes/_app/admin/mail'
import { Route as AppAdminSettingsRouteImport } from './routes/_app/admin/settings'
import { Route as AppDatahubIndexRouteImport } from './routes/_app/datahub/index'
-import { Route as AppDatahubSubjectIdRouteRouteImport } from './routes/_app/datahub/$subjectId/route'
import { Route as AppGroupEmailTemplatesRouteImport } from './routes/_app/group/email-templates'
import { Route as AppGroupManageRouteImport } from './routes/_app/group/manage'
import { Route as AppGroupRemoteAssignmentsRouteImport } from './routes/_app/group/remote-assignments'
@@ -40,11 +39,13 @@ import { Route as AppAdminInstrumentReposIndexRouteImport } from './routes/_app/
import { Route as AppAdminUsersIndexRouteImport } from './routes/_app/admin/users/index'
import { Route as AppAdminUsersUserIdRouteImport } from './routes/_app/admin/users/$userId'
import { Route as AppAdminUsersCreateRouteImport } from './routes/_app/admin/users/create'
-import { Route as AppDatahubSubjectIdAssignmentsRouteImport } from './routes/_app/datahub/$subjectId/assignments'
-import { Route as AppDatahubSubjectIdGraphRouteImport } from './routes/_app/datahub/$subjectId/graph'
+import { Route as AppDatahubSubjectsIndexRouteImport } from './routes/_app/datahub/subjects/index'
+import { Route as AppDatahubSubjectsSubjectIdRouteRouteImport } from './routes/_app/datahub/subjects/$subjectId/route'
import { Route as AppInstrumentsRenderIdRouteImport } from './routes/_app/instruments/render/$id'
-import { Route as AppDatahubSubjectIdTableIndexRouteImport } from './routes/_app/datahub/$subjectId/table/index'
-import { Route as AppDatahubSubjectIdTableRecordIdRouteImport } from './routes/_app/datahub/$subjectId/table/$recordId'
+import { Route as AppDatahubSubjectsSubjectIdAssignmentsRouteImport } from './routes/_app/datahub/subjects/$subjectId/assignments'
+import { Route as AppDatahubSubjectsSubjectIdGraphRouteImport } from './routes/_app/datahub/subjects/$subjectId/graph'
+import { Route as AppDatahubSubjectsSubjectIdTableIndexRouteImport } from './routes/_app/datahub/subjects/$subjectId/table/index'
+import { Route as AppDatahubSubjectsSubjectIdTableRecordIdRouteImport } from './routes/_app/datahub/subjects/$subjectId/table/$recordId'
const AppRouteRoute = AppRouteRouteImport.update({
id: '/_app',
@@ -110,12 +111,6 @@ const AppDatahubIndexRoute = AppDatahubIndexRouteImport.update({
path: '/datahub/',
getParentRoute: () => AppRouteRoute,
} as any)
-const AppDatahubSubjectIdRouteRoute =
- AppDatahubSubjectIdRouteRouteImport.update({
- id: '/datahub/$subjectId',
- path: '/datahub/$subjectId',
- getParentRoute: () => AppRouteRoute,
- } as any)
const AppGroupEmailTemplatesRoute = AppGroupEmailTemplatesRouteImport.update({
id: '/group/email-templates',
path: '/group/email-templates',
@@ -206,34 +201,45 @@ const AppAdminUsersCreateRoute = AppAdminUsersCreateRouteImport.update({
path: '/admin/users/create',
getParentRoute: () => AppRouteRoute,
} as any)
-const AppDatahubSubjectIdAssignmentsRoute =
- AppDatahubSubjectIdAssignmentsRouteImport.update({
- id: '/assignments',
- path: '/assignments',
- getParentRoute: () => AppDatahubSubjectIdRouteRoute,
- } as any)
-const AppDatahubSubjectIdGraphRoute =
- AppDatahubSubjectIdGraphRouteImport.update({
- id: '/graph',
- path: '/graph',
- getParentRoute: () => AppDatahubSubjectIdRouteRoute,
+const AppDatahubSubjectsIndexRoute = AppDatahubSubjectsIndexRouteImport.update({
+ id: '/datahub/subjects/',
+ path: '/datahub/subjects/',
+ getParentRoute: () => AppRouteRoute,
+} as any)
+const AppDatahubSubjectsSubjectIdRouteRoute =
+ AppDatahubSubjectsSubjectIdRouteRouteImport.update({
+ id: '/datahub/subjects/$subjectId',
+ path: '/datahub/subjects/$subjectId',
+ getParentRoute: () => AppRouteRoute,
} as any)
const AppInstrumentsRenderIdRoute = AppInstrumentsRenderIdRouteImport.update({
id: '/instruments/render/$id',
path: '/instruments/render/$id',
getParentRoute: () => AppRouteRoute,
} as any)
-const AppDatahubSubjectIdTableIndexRoute =
- AppDatahubSubjectIdTableIndexRouteImport.update({
+const AppDatahubSubjectsSubjectIdAssignmentsRoute =
+ AppDatahubSubjectsSubjectIdAssignmentsRouteImport.update({
+ id: '/assignments',
+ path: '/assignments',
+ getParentRoute: () => AppDatahubSubjectsSubjectIdRouteRoute,
+ } as any)
+const AppDatahubSubjectsSubjectIdGraphRoute =
+ AppDatahubSubjectsSubjectIdGraphRouteImport.update({
+ id: '/graph',
+ path: '/graph',
+ getParentRoute: () => AppDatahubSubjectsSubjectIdRouteRoute,
+ } as any)
+const AppDatahubSubjectsSubjectIdTableIndexRoute =
+ AppDatahubSubjectsSubjectIdTableIndexRouteImport.update({
id: '/table/',
path: '/table/',
- getParentRoute: () => AppDatahubSubjectIdRouteRoute,
+ getParentRoute: () => AppDatahubSubjectsSubjectIdRouteRoute,
} as any)
-const AppDatahubSubjectIdTableRecordIdRoute =
- AppDatahubSubjectIdTableRecordIdRouteImport.update({
+const AppDatahubSubjectsSubjectIdTableRecordIdRoute =
+ AppDatahubSubjectsSubjectIdTableRecordIdRouteImport.update({
id: '/table/$recordId',
path: '/table/$recordId',
- getParentRoute: () => AppDatahubSubjectIdRouteRoute,
+ getParentRoute: () => AppDatahubSubjectsSubjectIdRouteRoute,
} as any)
export interface FileRoutesByFullPath {
@@ -245,7 +251,6 @@ export interface FileRoutesByFullPath {
'/user': typeof AppUserRoute
'/auth/login': typeof AuthLoginRoute
'/auth/reset-password': typeof AuthResetPasswordRoute
- '/datahub/$subjectId': typeof AppDatahubSubjectIdRouteRouteWithChildren
'/admin/instruments': typeof AppAdminInstrumentsRoute
'/admin/mail': typeof AppAdminMailRoute
'/admin/settings': typeof AppAdminSettingsRoute
@@ -258,20 +263,22 @@ export interface FileRoutesByFullPath {
'/upload/$instrumentId': typeof AppUploadInstrumentIdRoute
'/datahub/': typeof AppDatahubIndexRoute
'/upload/': typeof AppUploadIndexRoute
+ '/datahub/subjects/$subjectId': typeof AppDatahubSubjectsSubjectIdRouteRouteWithChildren
'/admin/audit/logs': typeof AppAdminAuditLogsRoute
'/admin/branding/login-page': typeof AppAdminBrandingLoginPageRoute
'/admin/groups/create': typeof AppAdminGroupsCreateRoute
'/admin/users/$userId': typeof AppAdminUsersUserIdRoute
'/admin/users/create': typeof AppAdminUsersCreateRoute
- '/datahub/$subjectId/assignments': typeof AppDatahubSubjectIdAssignmentsRoute
- '/datahub/$subjectId/graph': typeof AppDatahubSubjectIdGraphRoute
'/instruments/render/$id': typeof AppInstrumentsRenderIdRoute
'/admin/branding/': typeof AppAdminBrandingIndexRoute
'/admin/groups/': typeof AppAdminGroupsIndexRoute
'/admin/instrument-repos/': typeof AppAdminInstrumentReposIndexRoute
'/admin/users/': typeof AppAdminUsersIndexRoute
- '/datahub/$subjectId/table/$recordId': typeof AppDatahubSubjectIdTableRecordIdRoute
- '/datahub/$subjectId/table/': typeof AppDatahubSubjectIdTableIndexRoute
+ '/datahub/subjects/': typeof AppDatahubSubjectsIndexRoute
+ '/datahub/subjects/$subjectId/assignments': typeof AppDatahubSubjectsSubjectIdAssignmentsRoute
+ '/datahub/subjects/$subjectId/graph': typeof AppDatahubSubjectsSubjectIdGraphRoute
+ '/datahub/subjects/$subjectId/table/$recordId': typeof AppDatahubSubjectsSubjectIdTableRecordIdRoute
+ '/datahub/subjects/$subjectId/table/': typeof AppDatahubSubjectsSubjectIdTableIndexRoute
}
export interface FileRoutesByTo {
'/setup': typeof SetupRoute
@@ -282,7 +289,6 @@ export interface FileRoutesByTo {
'/auth/login': typeof AuthLoginRoute
'/auth/reset-password': typeof AuthResetPasswordRoute
'/': typeof AppIndexRoute
- '/datahub/$subjectId': typeof AppDatahubSubjectIdRouteRouteWithChildren
'/admin/instruments': typeof AppAdminInstrumentsRoute
'/admin/mail': typeof AppAdminMailRoute
'/admin/settings': typeof AppAdminSettingsRoute
@@ -295,20 +301,22 @@ export interface FileRoutesByTo {
'/upload/$instrumentId': typeof AppUploadInstrumentIdRoute
'/datahub': typeof AppDatahubIndexRoute
'/upload': typeof AppUploadIndexRoute
+ '/datahub/subjects/$subjectId': typeof AppDatahubSubjectsSubjectIdRouteRouteWithChildren
'/admin/audit/logs': typeof AppAdminAuditLogsRoute
'/admin/branding/login-page': typeof AppAdminBrandingLoginPageRoute
'/admin/groups/create': typeof AppAdminGroupsCreateRoute
'/admin/users/$userId': typeof AppAdminUsersUserIdRoute
'/admin/users/create': typeof AppAdminUsersCreateRoute
- '/datahub/$subjectId/assignments': typeof AppDatahubSubjectIdAssignmentsRoute
- '/datahub/$subjectId/graph': typeof AppDatahubSubjectIdGraphRoute
'/instruments/render/$id': typeof AppInstrumentsRenderIdRoute
'/admin/branding': typeof AppAdminBrandingIndexRoute
'/admin/groups': typeof AppAdminGroupsIndexRoute
'/admin/instrument-repos': typeof AppAdminInstrumentReposIndexRoute
'/admin/users': typeof AppAdminUsersIndexRoute
- '/datahub/$subjectId/table/$recordId': typeof AppDatahubSubjectIdTableRecordIdRoute
- '/datahub/$subjectId/table': typeof AppDatahubSubjectIdTableIndexRoute
+ '/datahub/subjects': typeof AppDatahubSubjectsIndexRoute
+ '/datahub/subjects/$subjectId/assignments': typeof AppDatahubSubjectsSubjectIdAssignmentsRoute
+ '/datahub/subjects/$subjectId/graph': typeof AppDatahubSubjectsSubjectIdGraphRoute
+ '/datahub/subjects/$subjectId/table/$recordId': typeof AppDatahubSubjectsSubjectIdTableRecordIdRoute
+ '/datahub/subjects/$subjectId/table': typeof AppDatahubSubjectsSubjectIdTableIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -321,7 +329,6 @@ export interface FileRoutesById {
'/auth/login': typeof AuthLoginRoute
'/auth/reset-password': typeof AuthResetPasswordRoute
'/_app/': typeof AppIndexRoute
- '/_app/datahub/$subjectId': typeof AppDatahubSubjectIdRouteRouteWithChildren
'/_app/admin/instruments': typeof AppAdminInstrumentsRoute
'/_app/admin/mail': typeof AppAdminMailRoute
'/_app/admin/settings': typeof AppAdminSettingsRoute
@@ -334,20 +341,22 @@ export interface FileRoutesById {
'/_app/upload/$instrumentId': typeof AppUploadInstrumentIdRoute
'/_app/datahub/': typeof AppDatahubIndexRoute
'/_app/upload/': typeof AppUploadIndexRoute
+ '/_app/datahub/subjects/$subjectId': typeof AppDatahubSubjectsSubjectIdRouteRouteWithChildren
'/_app/admin/audit/logs': typeof AppAdminAuditLogsRoute
'/_app/admin/branding/login-page': typeof AppAdminBrandingLoginPageRoute
'/_app/admin/groups/create': typeof AppAdminGroupsCreateRoute
'/_app/admin/users/$userId': typeof AppAdminUsersUserIdRoute
'/_app/admin/users/create': typeof AppAdminUsersCreateRoute
- '/_app/datahub/$subjectId/assignments': typeof AppDatahubSubjectIdAssignmentsRoute
- '/_app/datahub/$subjectId/graph': typeof AppDatahubSubjectIdGraphRoute
'/_app/instruments/render/$id': typeof AppInstrumentsRenderIdRoute
'/_app/admin/branding/': typeof AppAdminBrandingIndexRoute
'/_app/admin/groups/': typeof AppAdminGroupsIndexRoute
'/_app/admin/instrument-repos/': typeof AppAdminInstrumentReposIndexRoute
'/_app/admin/users/': typeof AppAdminUsersIndexRoute
- '/_app/datahub/$subjectId/table/$recordId': typeof AppDatahubSubjectIdTableRecordIdRoute
- '/_app/datahub/$subjectId/table/': typeof AppDatahubSubjectIdTableIndexRoute
+ '/_app/datahub/subjects/': typeof AppDatahubSubjectsIndexRoute
+ '/_app/datahub/subjects/$subjectId/assignments': typeof AppDatahubSubjectsSubjectIdAssignmentsRoute
+ '/_app/datahub/subjects/$subjectId/graph': typeof AppDatahubSubjectsSubjectIdGraphRoute
+ '/_app/datahub/subjects/$subjectId/table/$recordId': typeof AppDatahubSubjectsSubjectIdTableRecordIdRoute
+ '/_app/datahub/subjects/$subjectId/table/': typeof AppDatahubSubjectsSubjectIdTableIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -360,7 +369,6 @@ export interface FileRouteTypes {
| '/user'
| '/auth/login'
| '/auth/reset-password'
- | '/datahub/$subjectId'
| '/admin/instruments'
| '/admin/mail'
| '/admin/settings'
@@ -373,20 +381,22 @@ export interface FileRouteTypes {
| '/upload/$instrumentId'
| '/datahub/'
| '/upload/'
+ | '/datahub/subjects/$subjectId'
| '/admin/audit/logs'
| '/admin/branding/login-page'
| '/admin/groups/create'
| '/admin/users/$userId'
| '/admin/users/create'
- | '/datahub/$subjectId/assignments'
- | '/datahub/$subjectId/graph'
| '/instruments/render/$id'
| '/admin/branding/'
| '/admin/groups/'
| '/admin/instrument-repos/'
| '/admin/users/'
- | '/datahub/$subjectId/table/$recordId'
- | '/datahub/$subjectId/table/'
+ | '/datahub/subjects/'
+ | '/datahub/subjects/$subjectId/assignments'
+ | '/datahub/subjects/$subjectId/graph'
+ | '/datahub/subjects/$subjectId/table/$recordId'
+ | '/datahub/subjects/$subjectId/table/'
fileRoutesByTo: FileRoutesByTo
to:
| '/setup'
@@ -397,7 +407,6 @@ export interface FileRouteTypes {
| '/auth/login'
| '/auth/reset-password'
| '/'
- | '/datahub/$subjectId'
| '/admin/instruments'
| '/admin/mail'
| '/admin/settings'
@@ -410,20 +419,22 @@ export interface FileRouteTypes {
| '/upload/$instrumentId'
| '/datahub'
| '/upload'
+ | '/datahub/subjects/$subjectId'
| '/admin/audit/logs'
| '/admin/branding/login-page'
| '/admin/groups/create'
| '/admin/users/$userId'
| '/admin/users/create'
- | '/datahub/$subjectId/assignments'
- | '/datahub/$subjectId/graph'
| '/instruments/render/$id'
| '/admin/branding'
| '/admin/groups'
| '/admin/instrument-repos'
| '/admin/users'
- | '/datahub/$subjectId/table/$recordId'
- | '/datahub/$subjectId/table'
+ | '/datahub/subjects'
+ | '/datahub/subjects/$subjectId/assignments'
+ | '/datahub/subjects/$subjectId/graph'
+ | '/datahub/subjects/$subjectId/table/$recordId'
+ | '/datahub/subjects/$subjectId/table'
id:
| '__root__'
| '/_app'
@@ -435,7 +446,6 @@ export interface FileRouteTypes {
| '/auth/login'
| '/auth/reset-password'
| '/_app/'
- | '/_app/datahub/$subjectId'
| '/_app/admin/instruments'
| '/_app/admin/mail'
| '/_app/admin/settings'
@@ -448,20 +458,22 @@ export interface FileRouteTypes {
| '/_app/upload/$instrumentId'
| '/_app/datahub/'
| '/_app/upload/'
+ | '/_app/datahub/subjects/$subjectId'
| '/_app/admin/audit/logs'
| '/_app/admin/branding/login-page'
| '/_app/admin/groups/create'
| '/_app/admin/users/$userId'
| '/_app/admin/users/create'
- | '/_app/datahub/$subjectId/assignments'
- | '/_app/datahub/$subjectId/graph'
| '/_app/instruments/render/$id'
| '/_app/admin/branding/'
| '/_app/admin/groups/'
| '/_app/admin/instrument-repos/'
| '/_app/admin/users/'
- | '/_app/datahub/$subjectId/table/$recordId'
- | '/_app/datahub/$subjectId/table/'
+ | '/_app/datahub/subjects/'
+ | '/_app/datahub/subjects/$subjectId/assignments'
+ | '/_app/datahub/subjects/$subjectId/graph'
+ | '/_app/datahub/subjects/$subjectId/table/$recordId'
+ | '/_app/datahub/subjects/$subjectId/table/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -564,13 +576,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppDatahubIndexRouteImport
parentRoute: typeof AppRouteRoute
}
- '/_app/datahub/$subjectId': {
- id: '/_app/datahub/$subjectId'
- path: '/datahub/$subjectId'
- fullPath: '/datahub/$subjectId'
- preLoaderRoute: typeof AppDatahubSubjectIdRouteRouteImport
- parentRoute: typeof AppRouteRoute
- }
'/_app/group/email-templates': {
id: '/_app/group/email-templates'
path: '/group/email-templates'
@@ -690,19 +695,19 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppAdminUsersCreateRouteImport
parentRoute: typeof AppRouteRoute
}
- '/_app/datahub/$subjectId/assignments': {
- id: '/_app/datahub/$subjectId/assignments'
- path: '/assignments'
- fullPath: '/datahub/$subjectId/assignments'
- preLoaderRoute: typeof AppDatahubSubjectIdAssignmentsRouteImport
- parentRoute: typeof AppDatahubSubjectIdRouteRoute
+ '/_app/datahub/subjects/': {
+ id: '/_app/datahub/subjects/'
+ path: '/datahub/subjects'
+ fullPath: '/datahub/subjects/'
+ preLoaderRoute: typeof AppDatahubSubjectsIndexRouteImport
+ parentRoute: typeof AppRouteRoute
}
- '/_app/datahub/$subjectId/graph': {
- id: '/_app/datahub/$subjectId/graph'
- path: '/graph'
- fullPath: '/datahub/$subjectId/graph'
- preLoaderRoute: typeof AppDatahubSubjectIdGraphRouteImport
- parentRoute: typeof AppDatahubSubjectIdRouteRoute
+ '/_app/datahub/subjects/$subjectId': {
+ id: '/_app/datahub/subjects/$subjectId'
+ path: '/datahub/subjects/$subjectId'
+ fullPath: '/datahub/subjects/$subjectId'
+ preLoaderRoute: typeof AppDatahubSubjectsSubjectIdRouteRouteImport
+ parentRoute: typeof AppRouteRoute
}
'/_app/instruments/render/$id': {
id: '/_app/instruments/render/$id'
@@ -711,42 +716,59 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppInstrumentsRenderIdRouteImport
parentRoute: typeof AppRouteRoute
}
- '/_app/datahub/$subjectId/table/': {
- id: '/_app/datahub/$subjectId/table/'
+ '/_app/datahub/subjects/$subjectId/assignments': {
+ id: '/_app/datahub/subjects/$subjectId/assignments'
+ path: '/assignments'
+ fullPath: '/datahub/subjects/$subjectId/assignments'
+ preLoaderRoute: typeof AppDatahubSubjectsSubjectIdAssignmentsRouteImport
+ parentRoute: typeof AppDatahubSubjectsSubjectIdRouteRoute
+ }
+ '/_app/datahub/subjects/$subjectId/graph': {
+ id: '/_app/datahub/subjects/$subjectId/graph'
+ path: '/graph'
+ fullPath: '/datahub/subjects/$subjectId/graph'
+ preLoaderRoute: typeof AppDatahubSubjectsSubjectIdGraphRouteImport
+ parentRoute: typeof AppDatahubSubjectsSubjectIdRouteRoute
+ }
+ '/_app/datahub/subjects/$subjectId/table/': {
+ id: '/_app/datahub/subjects/$subjectId/table/'
path: '/table'
- fullPath: '/datahub/$subjectId/table/'
- preLoaderRoute: typeof AppDatahubSubjectIdTableIndexRouteImport
- parentRoute: typeof AppDatahubSubjectIdRouteRoute
+ fullPath: '/datahub/subjects/$subjectId/table/'
+ preLoaderRoute: typeof AppDatahubSubjectsSubjectIdTableIndexRouteImport
+ parentRoute: typeof AppDatahubSubjectsSubjectIdRouteRoute
}
- '/_app/datahub/$subjectId/table/$recordId': {
- id: '/_app/datahub/$subjectId/table/$recordId'
+ '/_app/datahub/subjects/$subjectId/table/$recordId': {
+ id: '/_app/datahub/subjects/$subjectId/table/$recordId'
path: '/table/$recordId'
- fullPath: '/datahub/$subjectId/table/$recordId'
- preLoaderRoute: typeof AppDatahubSubjectIdTableRecordIdRouteImport
- parentRoute: typeof AppDatahubSubjectIdRouteRoute
+ fullPath: '/datahub/subjects/$subjectId/table/$recordId'
+ preLoaderRoute: typeof AppDatahubSubjectsSubjectIdTableRecordIdRouteImport
+ parentRoute: typeof AppDatahubSubjectsSubjectIdRouteRoute
}
}
}
-interface AppDatahubSubjectIdRouteRouteChildren {
- AppDatahubSubjectIdAssignmentsRoute: typeof AppDatahubSubjectIdAssignmentsRoute
- AppDatahubSubjectIdGraphRoute: typeof AppDatahubSubjectIdGraphRoute
- AppDatahubSubjectIdTableRecordIdRoute: typeof AppDatahubSubjectIdTableRecordIdRoute
- AppDatahubSubjectIdTableIndexRoute: typeof AppDatahubSubjectIdTableIndexRoute
+interface AppDatahubSubjectsSubjectIdRouteRouteChildren {
+ AppDatahubSubjectsSubjectIdAssignmentsRoute: typeof AppDatahubSubjectsSubjectIdAssignmentsRoute
+ AppDatahubSubjectsSubjectIdGraphRoute: typeof AppDatahubSubjectsSubjectIdGraphRoute
+ AppDatahubSubjectsSubjectIdTableRecordIdRoute: typeof AppDatahubSubjectsSubjectIdTableRecordIdRoute
+ AppDatahubSubjectsSubjectIdTableIndexRoute: typeof AppDatahubSubjectsSubjectIdTableIndexRoute
}
-const AppDatahubSubjectIdRouteRouteChildren: AppDatahubSubjectIdRouteRouteChildren =
+const AppDatahubSubjectsSubjectIdRouteRouteChildren: AppDatahubSubjectsSubjectIdRouteRouteChildren =
{
- AppDatahubSubjectIdAssignmentsRoute: AppDatahubSubjectIdAssignmentsRoute,
- AppDatahubSubjectIdGraphRoute: AppDatahubSubjectIdGraphRoute,
- AppDatahubSubjectIdTableRecordIdRoute:
- AppDatahubSubjectIdTableRecordIdRoute,
- AppDatahubSubjectIdTableIndexRoute: AppDatahubSubjectIdTableIndexRoute,
+ AppDatahubSubjectsSubjectIdAssignmentsRoute:
+ AppDatahubSubjectsSubjectIdAssignmentsRoute,
+ AppDatahubSubjectsSubjectIdGraphRoute:
+ AppDatahubSubjectsSubjectIdGraphRoute,
+ AppDatahubSubjectsSubjectIdTableRecordIdRoute:
+ AppDatahubSubjectsSubjectIdTableRecordIdRoute,
+ AppDatahubSubjectsSubjectIdTableIndexRoute:
+ AppDatahubSubjectsSubjectIdTableIndexRoute,
}
-const AppDatahubSubjectIdRouteRouteWithChildren =
- AppDatahubSubjectIdRouteRoute._addFileChildren(
- AppDatahubSubjectIdRouteRouteChildren,
+const AppDatahubSubjectsSubjectIdRouteRouteWithChildren =
+ AppDatahubSubjectsSubjectIdRouteRoute._addFileChildren(
+ AppDatahubSubjectsSubjectIdRouteRouteChildren,
)
interface AppRouteRouteChildren {
@@ -755,7 +777,6 @@ interface AppRouteRouteChildren {
AppDashboardRoute: typeof AppDashboardRoute
AppUserRoute: typeof AppUserRoute
AppIndexRoute: typeof AppIndexRoute
- AppDatahubSubjectIdRouteRoute: typeof AppDatahubSubjectIdRouteRouteWithChildren
AppAdminInstrumentsRoute: typeof AppAdminInstrumentsRoute
AppAdminMailRoute: typeof AppAdminMailRoute
AppAdminSettingsRoute: typeof AppAdminSettingsRoute
@@ -768,6 +789,7 @@ interface AppRouteRouteChildren {
AppUploadInstrumentIdRoute: typeof AppUploadInstrumentIdRoute
AppDatahubIndexRoute: typeof AppDatahubIndexRoute
AppUploadIndexRoute: typeof AppUploadIndexRoute
+ AppDatahubSubjectsSubjectIdRouteRoute: typeof AppDatahubSubjectsSubjectIdRouteRouteWithChildren
AppAdminAuditLogsRoute: typeof AppAdminAuditLogsRoute
AppAdminBrandingLoginPageRoute: typeof AppAdminBrandingLoginPageRoute
AppAdminGroupsCreateRoute: typeof AppAdminGroupsCreateRoute
@@ -778,6 +800,7 @@ interface AppRouteRouteChildren {
AppAdminGroupsIndexRoute: typeof AppAdminGroupsIndexRoute
AppAdminInstrumentReposIndexRoute: typeof AppAdminInstrumentReposIndexRoute
AppAdminUsersIndexRoute: typeof AppAdminUsersIndexRoute
+ AppDatahubSubjectsIndexRoute: typeof AppDatahubSubjectsIndexRoute
}
const AppRouteRouteChildren: AppRouteRouteChildren = {
@@ -786,7 +809,6 @@ const AppRouteRouteChildren: AppRouteRouteChildren = {
AppDashboardRoute: AppDashboardRoute,
AppUserRoute: AppUserRoute,
AppIndexRoute: AppIndexRoute,
- AppDatahubSubjectIdRouteRoute: AppDatahubSubjectIdRouteRouteWithChildren,
AppAdminInstrumentsRoute: AppAdminInstrumentsRoute,
AppAdminMailRoute: AppAdminMailRoute,
AppAdminSettingsRoute: AppAdminSettingsRoute,
@@ -800,6 +822,8 @@ const AppRouteRouteChildren: AppRouteRouteChildren = {
AppUploadInstrumentIdRoute: AppUploadInstrumentIdRoute,
AppDatahubIndexRoute: AppDatahubIndexRoute,
AppUploadIndexRoute: AppUploadIndexRoute,
+ AppDatahubSubjectsSubjectIdRouteRoute:
+ AppDatahubSubjectsSubjectIdRouteRouteWithChildren,
AppAdminAuditLogsRoute: AppAdminAuditLogsRoute,
AppAdminBrandingLoginPageRoute: AppAdminBrandingLoginPageRoute,
AppAdminGroupsCreateRoute: AppAdminGroupsCreateRoute,
@@ -810,6 +834,7 @@ const AppRouteRouteChildren: AppRouteRouteChildren = {
AppAdminGroupsIndexRoute: AppAdminGroupsIndexRoute,
AppAdminInstrumentReposIndexRoute: AppAdminInstrumentReposIndexRoute,
AppAdminUsersIndexRoute: AppAdminUsersIndexRoute,
+ AppDatahubSubjectsIndexRoute: AppDatahubSubjectsIndexRoute,
}
const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
diff --git a/apps/web/src/routes/_app/datahub/subjects/index.tsx b/apps/web/src/routes/_app/datahub/subjects/index.tsx
index 7e5e3e68f..8b8b349c6 100644
--- a/apps/web/src/routes/_app/datahub/subjects/index.tsx
+++ b/apps/web/src/routes/_app/datahub/subjects/index.tsx
@@ -49,14 +49,7 @@ type HasSearchStringFilter = {
/** A named window over the collection date, or an explicit one the user typed */
type CollectedPreset =
- | 'all'
- | 'custom'
- | 'pastMonth'
- | 'pastSixMonths'
- | 'pastThreeMonths'
- | 'pastTwoYears'
- | 'pastWeek'
- | 'pastYear';
+ 'all' | 'custom' | 'pastMonth' | 'pastSixMonths' | 'pastThreeMonths' | 'pastTwoYears' | 'pastWeek' | 'pastYear';
type CollectedFilter = {
max: Date | null;
@@ -111,7 +104,7 @@ const Filters: React.FC<{
@@ -167,7 +160,7 @@ const Filters: React.FC<{
{t('core.identificationData.dateOfBirth.label')}
-
+
{t({ en: 'Min:', es: 'Mín.:', fr: 'Min :' })}
-
+
{t({ en: 'Max:', es: 'Máx.:', fr: 'Max :' })}
+
{t({ en: 'At least:', es: 'Al menos:', fr: 'Au moins :' })}
setMinRecords(event.target.value)}
/>
-
+
{t({ en: 'Collected:', es: 'Recopilado:', fr: 'Collecté :' })}
{/* A native select renders with the user agent's own white background, which reads as a
bright block in dark mode — so it carries the popover surface tokens explicitly, and
@@ -244,16 +237,13 @@ const Filters: React.FC<{
value={collectedFilter.preset}
onChange={(event) => {
const preset = event.target.value as CollectedPreset;
- collectedColumn.setFilterValue(
- (prevValue: CollectedFilter): CollectedFilter => ({
- // A preset owns both bounds; only `custom` leaves them to the user, and it
- // starts from whatever window the preset had so the dates are a nudge, not blank.
- max: preset === 'custom' ? prevValue.max : null,
- min:
- preset === 'custom' ? (prevValue.min ?? presetMinDate(prevValue.preset)) : presetMinDate(preset),
- preset
- })
- );
+ collectedColumn.setFilterValue((prevValue: CollectedFilter): CollectedFilter => ({
+ // A preset owns both bounds; only `custom` leaves them to the user, and it
+ // starts from whatever window the preset had so the dates are a nudge, not blank.
+ max: preset === 'custom' ? prevValue.max : null,
+ min: preset === 'custom' ? (prevValue.min ?? presetMinDate(prevValue.preset)) : presetMinDate(preset),
+ preset
+ }));
}}
>
{t({ en: 'Any time', es: 'Cualquier fecha', fr: 'Toute période' })}
@@ -268,7 +258,7 @@ const Filters: React.FC<{
{collectedFilter.preset === 'custom' && (
-
+
{t({ en: 'Min:', es: 'Mín.:', fr: 'Min :' })}
{
- collectedColumn.setFilterValue(
- (prevValue: CollectedFilter): CollectedFilter => ({ ...prevValue, min: event.target.valueAsDate })
- );
+ collectedColumn.setFilterValue((prevValue: CollectedFilter): CollectedFilter => ({
+ ...prevValue,
+ min: event.target.valueAsDate
+ }));
}}
/>
-
+
{t({ en: 'Max:', es: 'Máx.:', fr: 'Max :' })}
{
- collectedColumn.setFilterValue(
- (prevValue: CollectedFilter): CollectedFilter => ({ ...prevValue, max: event.target.valueAsDate })
- );
+ collectedColumn.setFilterValue((prevValue: CollectedFilter): CollectedFilter => ({
+ ...prevValue,
+ max: event.target.valueAsDate
+ }));
}}
/>
@@ -414,11 +406,11 @@ const Toggles: React.FC<{
};
return (
-
+
{
setSearchString(value);
const subjectIdColumn = table.getColumn('subjectId')!;
- subjectIdColumn.setFilterValue(
- (prevValue: HasSearchStringFilter): HasSearchStringFilter => ({
- ...prevValue,
- searchString: value
- })
- );
+ subjectIdColumn.setFilterValue((prevValue: HasSearchStringFilter): HasSearchStringFilter => ({
+ ...prevValue,
+ searchString: value
+ }));
}}
/>
diff --git a/testing/src/pages/_app/datahub/subjects/index.page.ts b/testing/src/pages/_app/datahub/subjects/index.page.ts
index 4918f0d93..273d8c1a2 100644
--- a/testing/src/pages/_app/datahub/subjects/index.page.ts
+++ b/testing/src/pages/_app/datahub/subjects/index.page.ts
@@ -45,23 +45,22 @@ export class DatahubPage extends AppPage {
* loaded for the column rather than refetching.
*/
async requireAtLeastRecords(count: number) {
- await this.filtersTrigger.click();
+ await this.openFilters();
await this.minRecordsFilter.fill(String(count));
}
/** Opens the filter menu and picks a collection-date window. */
async selectCollectedWindow(
preset:
- | 'all'
- | 'custom'
- | 'pastMonth'
- | 'pastSixMonths'
- | 'pastThreeMonths'
- | 'pastTwoYears'
- | 'pastWeek'
- | 'pastYear'
+ 'all' | 'custom' | 'pastMonth' | 'pastSixMonths' | 'pastThreeMonths' | 'pastTwoYears' | 'pastWeek' | 'pastYear'
) {
- await this.filtersTrigger.click();
+ await this.openFilters();
await this.collectedPresetFilter.selectOption(preset);
}
+
+ private async openFilters() {
+ if ((await this.filtersTrigger.getAttribute('data-state')) !== 'open') {
+ await this.filtersTrigger.click();
+ }
+ }
}
From c7c571b67f3329b8a532333399affdcc7286bdef Mon Sep 17 00:00:00 2001
From: thomasbeaudry
Date: Mon, 5 Oct 2026 12:50:33 -0400
Subject: [PATCH 5/6] test: point main's new suites at the relocated datahub
routes
Main's test-coverage work landed suites that assert the pre-move
`/datahub/...` addresses, and git merged them cleanly because the
disagreement is semantic rather than textual:
- the walkthrough, dashboard, nav-item, subject-layout and
subject-assignments tests now expect `/datahub/subjects/...`
- `instrument-records.service.spec.ts` declared `groupsService` twice,
once from each side of the merge, which made the file a syntax error
rather than a failing test
Co-Authored-By: Claude Opus 5
---
.../__tests__/instrument-records.service.spec.ts | 2 --
.../web/src/__tests__/WalkthroughProvider.test.tsx | 2 +-
apps/web/src/__tests__/dashboard-route.test.tsx | 2 +-
.../__tests__/datahub-subject-assignments.test.tsx | 4 +++-
.../src/__tests__/datahub-subject-layout.test.tsx | 14 ++++++++------
apps/web/src/hooks/__tests__/useNavItems.test.ts | 2 +-
6 files changed, 14 insertions(+), 12 deletions(-)
diff --git a/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts b/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts
index ce94aff10..f081a5ce7 100644
--- a/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts
+++ b/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts
@@ -65,7 +65,6 @@ describe('InstrumentRecordsService', () => {
let instrumentRecordsService: InstrumentRecordsService;
let instrumentRecordModel: MockedInstance>;
let sessionModel: MockedInstance>;
- let groupsService: MockedInstance;
let instrumentsService: MockedInstance;
let sessionsService: MockedInstance;
let subjectsService: MockedInstance;
@@ -112,7 +111,6 @@ describe('InstrumentRecordsService', () => {
instrumentRecordModel = moduleRef.get(getModelToken('InstrumentRecord'));
sessionModel = moduleRef.get(getModelToken('Session'));
instrumentRecordsService = moduleRef.get(InstrumentRecordsService);
- groupsService = moduleRef.get(GroupsService);
instrumentsService = moduleRef.get(InstrumentsService);
sessionsService = moduleRef.get(SessionsService);
subjectsService = moduleRef.get(SubjectsService);
diff --git a/apps/web/src/__tests__/WalkthroughProvider.test.tsx b/apps/web/src/__tests__/WalkthroughProvider.test.tsx
index dbee2c52e..b93fe8f5a 100644
--- a/apps/web/src/__tests__/WalkthroughProvider.test.tsx
+++ b/apps/web/src/__tests__/WalkthroughProvider.test.tsx
@@ -239,7 +239,7 @@ describe('Walkthrough', () => {
it('should center a bottom-center popover under its target', async () => {
stubPopoverSize(120, 60);
- addTarget(' ', { bottom: 80, left: 100, right: 300, top: 50 });
+ addTarget(' ', { bottom: 80, left: 100, right: 300, top: 50 });
renderProvider();
clickNext(13);
await waitFor(() => expect(popoverPosition('Table')).toEqual({ x: 140, y: 100 }));
diff --git a/apps/web/src/__tests__/dashboard-route.test.tsx b/apps/web/src/__tests__/dashboard-route.test.tsx
index b3d63d015..cb85de61a 100644
--- a/apps/web/src/__tests__/dashboard-route.test.tsx
+++ b/apps/web/src/__tests__/dashboard-route.test.tsx
@@ -209,7 +209,7 @@ describe('dashboard page', () => {
it('should open the data hub from the subjects statistic', () => {
renderPage();
fireEvent.click(screen.getByTestId('statistic-subjects'));
- expect(mocks.navigate).toHaveBeenCalledWith({ to: '/datahub' });
+ expect(mocks.navigate).toHaveBeenCalledWith({ to: '/datahub/subjects' });
});
it('should list every user of the group in the users dialog', () => {
diff --git a/apps/web/src/__tests__/datahub-subject-assignments.test.tsx b/apps/web/src/__tests__/datahub-subject-assignments.test.tsx
index 39210b6e1..00bd7b809 100644
--- a/apps/web/src/__tests__/datahub-subject-assignments.test.tsx
+++ b/apps/web/src/__tests__/datahub-subject-assignments.test.tsx
@@ -97,7 +97,9 @@ describe('subject assignments route guard', () => {
mocks.config.setup.isGatewayEnabled = false;
const thrown = runGuard();
expect(isRedirect(thrown)).toBe(true);
- expect(thrown).toMatchObject({ options: { params: { subjectId: 'subject-1' }, to: '/datahub/$subjectId/table' } });
+ expect(thrown).toMatchObject({
+ options: { params: { subjectId: 'subject-1' }, to: '/datahub/subjects/$subjectId/table' }
+ });
});
it('should allow the route when the gateway is deployed', () => {
diff --git a/apps/web/src/__tests__/datahub-subject-layout.test.tsx b/apps/web/src/__tests__/datahub-subject-layout.test.tsx
index b89139757..f99f791c7 100644
--- a/apps/web/src/__tests__/datahub-subject-layout.test.tsx
+++ b/apps/web/src/__tests__/datahub-subject-layout.test.tsx
@@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({
config: { setup: { isGatewayEnabled: true } },
currentGroup: null as null | { settings: { subjectIdDisplayLength?: number } },
outlet: (): ReactNode => 'Outlet Content',
- pathname: '/datahub/root$abcdefghijkl/table'
+ pathname: '/datahub/subjects/root$abcdefghijkl/table'
}));
vi.mock('@tanstack/react-router', async (importOriginal) => ({
@@ -40,7 +40,7 @@ beforeEach(() => {
mocks.config.setup.isGatewayEnabled = true;
mocks.currentGroup = null;
mocks.outlet = () => 'Outlet Content';
- mocks.pathname = '/datahub/root$abcdefghijkl/table';
+ mocks.pathname = '/datahub/subjects/root$abcdefghijkl/table';
vi.spyOn(Route, 'useParams').mockReturnValue({ subjectId: 'root$abcdefghijkl' });
});
@@ -63,12 +63,14 @@ describe('subject layout route', () => {
it('should link each tab to its page under the subject', () => {
renderLayout();
- expect(screen.getByTestId('subject-table-tab').getAttribute('href')).toBe('/datahub/root$abcdefghijkl/table');
- expect(screen.getByTestId('subject-graph').getAttribute('href')).toBe('/datahub/root$abcdefghijkl/graph');
+ expect(screen.getByTestId('subject-table-tab').getAttribute('href')).toBe(
+ '/datahub/subjects/root$abcdefghijkl/table'
+ );
+ expect(screen.getByTestId('subject-graph').getAttribute('href')).toBe('/datahub/subjects/root$abcdefghijkl/graph');
});
it('should highlight only the tab whose page is open', () => {
- mocks.pathname = '/datahub/root$abcdefghijkl/graph';
+ mocks.pathname = '/datahub/subjects/root$abcdefghijkl/graph';
renderLayout();
expect(screen.getByTestId('subject-graph').classList.contains('border-sky-500')).toBe(true);
expect(screen.getByTestId('subject-table-tab').classList.contains('border-sky-500')).toBe(false);
@@ -77,7 +79,7 @@ describe('subject layout route', () => {
it('should offer the assignments tab when the gateway is deployed', () => {
renderLayout();
expect(screen.getByTestId('subject-assignment').getAttribute('href')).toBe(
- '/datahub/root$abcdefghijkl/assignments'
+ '/datahub/subjects/root$abcdefghijkl/assignments'
);
});
diff --git a/apps/web/src/hooks/__tests__/useNavItems.test.ts b/apps/web/src/hooks/__tests__/useNavItems.test.ts
index ed0fbd53a..801642733 100644
--- a/apps/web/src/hooks/__tests__/useNavItems.test.ts
+++ b/apps/web/src/hooks/__tests__/useNavItems.test.ts
@@ -207,6 +207,6 @@ describe('useNavItems', () => {
mocks.store.currentSession = { subjectId: 'subject-1' };
expect(navItemAt('/session/start-session')?.disabled).toBe(true);
expect(navItemAt('/instruments/accessible-instruments')?.disabled).toBe(false);
- expect(navItemAt('/datahub/subject-1/table')?.disabled).toBe(false);
+ expect(navItemAt('/datahub/subjects/subject-1/table')?.disabled).toBe(false);
});
});
From 758c76a901ddf9159648c95eb6c01d6007b13084 Mon Sep 17 00:00:00 2001
From: thomasbeaudry
Date: Tue, 6 Oct 2026 22:58:45 -0400
Subject: [PATCH 6/6] test(web): read the collected-date inputs through a typed
query
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`eslint --fix` strips the `as HTMLInputElement` the assertions needed, and `lint` type-checks
before it fixes — so the cast survived the local run and failed `tsc` on CI. Asking
`getByTestId` for the element type instead leaves nothing for the fixer to remove.
Co-Authored-By: Claude Opus 5
---
.../_app/datahub/subjects/__tests__/index.test.tsx | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/routes/_app/datahub/subjects/__tests__/index.test.tsx b/apps/web/src/routes/_app/datahub/subjects/__tests__/index.test.tsx
index 4cd45d828..2d6e30dc3 100644
--- a/apps/web/src/routes/_app/datahub/subjects/__tests__/index.test.tsx
+++ b/apps/web/src/routes/_app/datahub/subjects/__tests__/index.test.tsx
@@ -334,6 +334,9 @@ describe('data hub collected filter', () => {
fireEvent.change(screen.getByTestId('datahub-filter-collected-preset'), { target: { value: preset } });
};
+ const collectedInput = (bound: 'max' | 'min') =>
+ screen.getByTestId(`datahub-filter-collected-${bound}`);
+
beforeEach(() => {
mocks.summaries = [
{ lastCollectedAt: OVER_A_YEAR_AGO, recordCount: 1, subjectId: ALICE.id },
@@ -380,7 +383,7 @@ describe('data hub collected filter', () => {
openFilters();
selectPreset('pastWeek');
selectPreset('custom');
- expect(screen.getByTestId('datahub-filter-collected-min').value).not.toBe('');
+ expect(collectedInput('min').value).not.toBe('');
});
it('should keep the chosen custom bounds in the inputs when the menu is reopened', () => {
@@ -390,8 +393,8 @@ describe('data hub collected filter', () => {
fireEvent.change(screen.getByTestId('datahub-filter-collected-min'), { target: { value: '2025-01-01' } });
fireEvent.change(screen.getByTestId('datahub-filter-collected-max'), { target: { value: '2027-01-01' } });
reopenFilters();
- expect(screen.getByTestId('datahub-filter-collected-min').value).toBe('2025-01-01');
- expect(screen.getByTestId('datahub-filter-collected-max').value).toBe('2027-01-01');
+ expect(collectedInput('min').value).toBe('2025-01-01');
+ expect(collectedInput('max').value).toBe('2027-01-01');
});
});