diff --git a/apps/api/src/instrument-records/__tests__/instrument-records.controller.test.ts b/apps/api/src/instrument-records/__tests__/instrument-records.controller.test.ts index 1ee9a276e..6353ca2f3 100644 --- a/apps/api/src/instrument-records/__tests__/instrument-records.controller.test.ts +++ b/apps/api/src/instrument-records/__tests__/instrument-records.controller.test.ts @@ -72,6 +72,13 @@ describe('InstrumentRecordsController', () => { ); }); + it('should summarize the records of the requested group, scoped to the caller ability', async () => { + const summary = [{ lastCollectedAt: new Date('2025-01-01'), recordCount: 2, subjectId: 'subject-1' }]; + instrumentRecordsService.summarizeBySubject.mockResolvedValueOnce(summary); + await expect(instrumentRecordsController.summarizeBySubject(ability, 'group-1')).resolves.toBe(summary); + expect(instrumentRecordsService.summarizeBySubject).toHaveBeenCalledWith({ groupId: 'group-1' }, { ability }); + }); + it('should update the record with the unwrapped data, scoped to the caller ability', async () => { await instrumentRecordsController.updateById('record-1', { data: { score: 2 } }, ability); expect(instrumentRecordsService.updateById).toHaveBeenCalledWith('record-1', { score: 2 }, { ability }); diff --git a/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts b/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts index 47b823d19..1a18a1bba 100644 --- a/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts +++ b/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts @@ -1182,4 +1182,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 18355776c..877a17474 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'; @@ -352,6 +353,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/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 ( - - ); -}; - type SourceStepProps = { onBack?: () => void; onParsed: (parsed: BulkParseResult) => void; 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/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 ( - - ); -}; - const StatusCell = ({ status }: { status: 'EXPIRED' | 'OUTSTANDING' }) => { const { t } = useTranslation(); if (status === 'OUTSTANDING') { 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 ( + + ); +}; diff --git a/apps/web/src/components/SortableHeader/__tests__/SortableHeader.test.tsx b/apps/web/src/components/SortableHeader/__tests__/SortableHeader.test.tsx new file mode 100644 index 000000000..1a83c48af --- /dev/null +++ b/apps/web/src/components/SortableHeader/__tests__/SortableHeader.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/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..801642733 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'); }); @@ -191,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); }); }); 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..02d9cfb0b --- /dev/null +++ b/apps/web/src/hooks/__tests__/useSubjectRecordSummaryQuery.test.ts @@ -0,0 +1,70 @@ +import type { PropsWithChildren } from 'react'; +import { createElement, Suspense } from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import axios from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { subjectRecordSummaryQueryOptions, useSubjectRecordSummaryQuery } from '../useSubjectRecordSummaryQuery'; + +vi.mock('axios'); + +const runQuery = (groupId?: string) => + new QueryClient().fetchQuery(subjectRecordSummaryQueryOptions({ params: { groupId } })); + +const renderSummaryQuery = (groupId?: string) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: PropsWithChildren) => + createElement(QueryClientProvider, { client: queryClient }, createElement(Suspense, { fallback: null }, children)); + return renderHook(() => useSubjectRecordSummaryQuery({ params: { groupId } }), { wrapper }); +}; + +// 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 suspend until the summary resolves, so the table never renders a row without its counts', async () => { + get.mockResolvedValueOnce({ data: [{ lastCollectedAt: null, recordCount: 0, subjectId: 'subject-1' }] }); + const { result } = renderSummaryQuery('group-1'); + await waitFor(() => expect(result.current?.data).toHaveLength(1)); + }); + + 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/providers/__tests__/WalkthroughProvider.test.tsx b/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx index dbee2c52e..b93fe8f5a 100644 --- a/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx +++ b/apps/web/src/providers/__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/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/__tests__/dashboard.test.tsx b/apps/web/src/routes/_app/__tests__/dashboard.test.tsx index b3d63d015..cb85de61a 100644 --- a/apps/web/src/routes/_app/__tests__/dashboard.test.tsx +++ b/apps/web/src/routes/_app/__tests__/dashboard.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/routes/_app/dashboard.tsx b/apps/web/src/routes/_app/dashboard.tsx index 37d009249..7525e5579 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/__tests__/index.test.ts b/apps/web/src/routes/_app/datahub/__tests__/index.test.ts new file mode 100644 index 000000000..e242442c8 --- /dev/null +++ b/apps/web/src/routes/_app/datahub/__tests__/index.test.ts @@ -0,0 +1,22 @@ +import { isRedirect } from '@tanstack/react-router'; +import { describe, expect, it } from 'vitest'; + +import { Route } from '@/routes/_app/datahub/index'; + +const runBeforeLoad = () => { + const beforeLoad = Route.options.beforeLoad as () => void; + try { + beforeLoad(); + } catch (err) { + return err; + } + return null; +}; + +describe('_app datahub index route', () => { + it('should redirect to the subject hub, so a bookmarked /datahub link still lands on a page', () => { + const thrown = runBeforeLoad(); + expect(isRedirect(thrown)).toBe(true); + expect(thrown).toMatchObject({ options: { 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('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('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/__tests__/assignments.test.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/assignments.test.tsx similarity index 97% rename from apps/web/src/routes/_app/datahub/$subjectId/__tests__/assignments.test.tsx rename to apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/assignments.test.tsx index 2fdbf2c24..00bd7b809 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/__tests__/assignments.test.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/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'; @@ -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/routes/_app/datahub/$subjectId/__tests__/graph.test.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/graph.test.tsx similarity index 99% rename from apps/web/src/routes/_app/datahub/$subjectId/__tests__/graph.test.tsx rename to apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/graph.test.tsx index cbf0c6b4a..d3eeb01c5 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/__tests__/graph.test.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/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/routes/_app/datahub/$subjectId/__tests__/route.test.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/route.test.tsx similarity index 88% rename from apps/web/src/routes/_app/datahub/$subjectId/__tests__/route.test.tsx rename to apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/route.test.tsx index 2d6f055ad..f99f791c7 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/__tests__/route.test.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/route.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'; @@ -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/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 1e5773b2f..4ea8d444c 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/graph.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx @@ -175,6 +175,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 3b0ba4d42..2f4fca39d 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/table/$recordId.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/$recordId.tsx @@ -146,7 +146,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/__tests__/$recordId.test.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/__tests__/$recordId.test.tsx similarity index 99% rename from apps/web/src/routes/_app/datahub/$subjectId/table/__tests__/$recordId.test.tsx rename to apps/web/src/routes/_app/datahub/subjects/$subjectId/table/__tests__/$recordId.test.tsx index 4145c60ce..638e16e5e 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/table/__tests__/$recordId.test.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/__tests__/$recordId.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/routes/_app/datahub/$subjectId/table/__tests__/index.test.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/__tests__/index.test.tsx similarity index 98% rename from apps/web/src/routes/_app/datahub/$subjectId/table/__tests__/index.test.tsx rename to apps/web/src/routes/_app/datahub/subjects/$subjectId/table/__tests__/index.test.tsx index dbe551ad8..e03edb878 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/table/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/__tests__/index.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/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/__tests__/index.test.tsx b/apps/web/src/routes/_app/datahub/subjects/__tests__/index.test.tsx similarity index 72% rename from apps/web/src/routes/_app/datahub/__tests__/index.test.tsx rename to apps/web/src/routes/_app/datahub/subjects/__tests__/index.test.tsx index a93a02300..2d6e30dc3 100644 --- a/apps/web/src/routes/_app/datahub/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/__tests__/index.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,11 @@ 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[], + theme: 'light' })); vi.mock('@tanstack/react-router', async (importOriginal) => ({ @@ -42,7 +44,8 @@ vi.mock('@douglasneuroinformatics/libui/hooks', async (importOriginal) => ({ ...(await importOriginal()), useDownload: () => mocks.download, useNotificationsStore: (selector: (store: { addNotification: typeof mocks.addNotification }) => unknown) => - selector({ addNotification: mocks.addNotification }) + selector({ addNotification: mocks.addNotification }), + useTheme: () => [mocks.theme] })); vi.mock('axios', () => ({ default: mocks.axios })); vi.mock('@/components/IdentificationForm', () => ({ @@ -52,9 +55,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), { @@ -138,8 +143,10 @@ const settleExport = async () => { beforeEach(() => { vi.clearAllMocks(); mocks.store.currentGroup = { id: 'group-1', settings: {} }; + mocks.theme = 'light'; 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 +166,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]!); @@ -204,6 +223,15 @@ describe('data hub subject columns', () => { expect(sexes).toEqual(['Male', 'Female', 'NULL']); }); + // The unrecorded-sex tag is the one grey that has to change with the surface it sits on, since a + // single grey cannot stay visible against both. + it('should lighten the unknown sex tag in dark mode', () => { + mocks.theme = 'dark'; + renderDataHub(); + const dot = screen.getAllByTestId('subject-cell-sex')[2]!.firstElementChild as HTMLElement; + expect(dot.style.backgroundColor).toBe('#a3a3a3'); + }); + it('should match the search against subject ids case-insensitively', () => { renderDataHub(); fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'BOB' } }); @@ -270,12 +298,104 @@ 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.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 collected filter', () => { + const daysAgo = (count: number) => new Date(Date.now() - count * 24 * 60 * 60 * 1000); + + const RECENT = daysAgo(3); + const OVER_A_YEAR_AGO = daysAgo(400); + + const selectPreset = (preset: string) => { + 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 }, + { lastCollectedAt: RECENT, recordCount: 2, subjectId: BOB.id } + ]; + }); + + // Carol has never had a record collected, so she belongs to "any time" and to no window. + it.each([ + ['all', ['alice-123', 'bob', 'carol']], + ['pastWeek', ['bob']], + ['pastMonth', ['bob']], + ['pastThreeMonths', ['bob']], + ['pastSixMonths', ['bob']], + ['pastYear', ['bob']], + ['pastTwoYears', ['alice-123', 'bob']] + ])('should list the subjects collected within the %s window', (preset, expected) => { + renderDataHub(); + openFilters(); + selectPreset(preset); + expect(listedSubjects()).toEqual(expected); + }); + + it('should hide a subject collected before a custom minimum date', () => { renderDataHub(); openFilters(); - fireEvent.click(screen.getByTestId('datahub-filter-has-records')); + selectPreset('custom'); + fireEvent.change(screen.getByTestId('datahub-filter-collected-min'), { target: { value: '2026-01-01' } }); expect(listedSubjects()).toEqual(['bob']); }); + + it('should hide a subject collected after a custom maximum date', () => { + renderDataHub(); + openFilters(); + selectPreset('custom'); + fireEvent.change(screen.getByTestId('datahub-filter-collected-max'), { target: { value: '2026-01-01' } }); + expect(listedSubjects()).toEqual(['alice-123']); + }); + + // Switching to a custom window starts from the preset's own bounds, so the dates are a nudge + // rather than two blank inputs. + it('should seed the custom minimum from the preset the user is leaving', () => { + renderDataHub(); + openFilters(); + selectPreset('pastWeek'); + selectPreset('custom'); + expect(collectedInput('min').value).not.toBe(''); + }); + + it('should keep the chosen custom bounds in the inputs when the menu is reopened', () => { + renderDataHub(); + openFilters(); + selectPreset('custom'); + 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(collectedInput('min').value).toBe('2025-01-01'); + expect(collectedInput('max').value).toBe('2027-01-01'); + }); }); describe('data hub subject lookup', () => { 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..8b8b349c6 --- /dev/null +++ b/apps/web/src/routes/_app/datahub/subjects/index.tsx @@ -0,0 +1,695 @@ +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('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. */} + +
+ {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('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/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() +}); 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/index.page.ts b/testing/src/pages/_app/datahub/subjects/index.page.ts similarity index 58% rename from testing/src/pages/_app/datahub/index.page.ts rename to testing/src/pages/_app/datahub/subjects/index.page.ts index 59e05b930..273d8c1a2 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,28 @@ export class DatahubPage extends AppPage { return started; } - /** Opens the filter menu and toggles "With records only", which refetches with `hasRecord=true`. */ - async toggleWithRecordsOnly() { - await this.filtersTrigger.click(); - await this.hasRecordsFilter.click(); + /** + * 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.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' + ) { + await this.openFilters(); + await this.collectedPresetFilter.selectOption(preset); + } + + private async openFilters() { + if ((await this.filtersTrigger.getAttribute('data-state')) !== 'open') { + await this.filtersTrigger.click(); + } } } 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 bc22efa38..d87cff91c 100644 --- a/testing/src/support/fixtures.ts +++ b/testing/src/support/fixtures.ts @@ -16,8 +16,8 @@ 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 { DatahubPage } from '../pages/_app/datahub/index.page'; +import { SubjectAssignmentsPage } from '../pages/_app/datahub/subjects/$subjectId/assignments.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 { AccessibleInstrumentsPage } from '../pages/_app/instruments/accessible-instruments.page'; @@ -45,8 +45,8 @@ const pageModels = { '/auth/login': LoginPage, '/contact': ContactPage, '/dashboard': DashboardPage, - '/datahub': DatahubPage, - '/datahub/$subjectId/assignments': SubjectAssignmentsPage, + '/datahub/subjects': DatahubPage, + '/datahub/subjects/$subjectId/assignments': SubjectAssignmentsPage, '/group/email-templates': GroupEmailTemplatesPage, '/group/manage': GroupManagePage, '/instruments/accessible-instruments': AccessibleInstrumentsPage,