Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 186 additions & 15 deletions apps/web/src/routes/_app/datahub/index.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import React, { useState } from 'react';

import { toBasicISOString } from '@douglasneuroinformatics/libjs';
import { ActionDropdown, Button, DataTable, Dialog, Heading } from '@douglasneuroinformatics/libui/components';
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 { Subject } from '@opendatacapture/schemas/subject';
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 { UserSearchIcon } from 'lucide-react';
import { ChevronDownIcon, UserSearchIcon } from 'lucide-react';
import { unpack } from 'msgpackr/unpack';
import { unparse } from 'papaparse';

Expand All @@ -19,9 +26,133 @@ import { subjectsQueryOptions, useSubjectsQuery } from '@/hooks/useSubjectsQuery
import { useAppStore } from '@/store';
import { downloadExcel } from '@/utils/excel';

type MasterDataTableProps = {
data: Subject[];
onSelect: (subject: Subject) => void;
type DateFilter = {
allowNull: boolean;
max: Date | null;
min: Date | null;
};

type SexFilter = (null | Sex)[];

const Filters: React.FC<{ table: TanstackTable.Table<Subject> }> = ({ table }) => {
const { t } = useTranslation();

const [isOpen, setIsOpen] = useState(false);

const columns = table.getAllColumns();

const dobColumn = columns.find((column) => column.id === 'date-of-birth')!;
const dobFilter = dobColumn.getFilterValue() as DateFilter;

const sexColumn = columns.find((column) => column.id === 'sex')!;
const sexFilter = sexColumn.getFilterValue() as SexFilter;

return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu.Trigger asChild>
<Button className="flex items-center justify-between gap-2" variant="outline">
{t({ en: 'Filters', fr: 'Filtres' })}
<ChevronDownIcon className="opacity-50" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" className="w-56">
<DropdownMenu.Label>{t('core.identificationData.sex.label')}</DropdownMenu.Label>
<DropdownMenu.Group>
<DropdownMenu.CheckboxItem
checked={sexFilter.includes('MALE')}
onCheckedChange={(checked) => {
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')}
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sexFilter.includes('FEMALE')}
onCheckedChange={(checked) => {
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')}
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sexFilter.includes(null)}
onCheckedChange={(checked) => {
sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => {
if (checked) {
return [...prevValue, null];
}
return prevValue.filter((item) => item !== null);
});
}}
onSelect={(e) => e.preventDefault()}
>
NULL
</DropdownMenu.CheckboxItem>
</DropdownMenu.Group>
<DropdownMenu.Label>{t('core.identificationData.dateOfBirth.label')}</DropdownMenu.Label>
<DropdownMenu.Group>
<div className="rounded-xs relative flex items-center justify-between gap-1 px-2 pb-1 pt-1.5 text-sm transition-colors">
<span className="pb-1">Min:</span>
<input
className="text-muted-foreground pointer-events-auto rounded-sm border-b pb-0.5"
type="date"
value={dobFilter.min ? toBasicISOString(dobFilter.min) : ''}
onChange={(event) => {
dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => {
return {
...prevValue,
min: event.target.valueAsDate
};
});
}}
/>
</div>
<div className="rounded-xs relative flex items-center justify-between gap-1 px-2 pb-1 pt-1.5 text-sm transition-colors">
<span className="pb-1">Max:</span>
Comment on lines +107 to +123
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Untranslated labels: "Min:" and "Max:"

These labels should use the translation function for consistency with the rest of the UI.

Proposed fix
-            <span className="pb-1">Min:</span>
+            <span className="pb-1">{t({ en: 'Min:', fr: 'Min :' })}</span>
-            <span className="pb-1">Max:</span>
+            <span className="pb-1">{t({ en: 'Max:', fr: 'Max :' })}</span>
🤖 Prompt for AI Agents
In `@apps/web/src/routes/_app/datahub/index.tsx` around lines 107 - 123, Replace
the hardcoded "Min:" and "Max:" span labels with calls to the app's translation
function used in this file (e.g., t('...') or i18n.t('...')) so they render
localized strings; update both span elements near the Date input (the spans just
before the inputs that reference dobFilter and dobColumn) to use translation
keys like "datahub.dob.min" and "datahub.dob.max" (or appropriate existing keys)
and provide sensible fallback text if your translation helper supports it.

<input
className="text-muted-foreground pointer-events-auto rounded-sm border-b pb-0.5"
type="date"
value={dobFilter.max ? toBasicISOString(dobFilter.max) : ''}
onChange={(event) => {
dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => {
return {
...prevValue,
max: event.target.valueAsDate
};
});
}}
/>
</div>
<DropdownMenu.CheckboxItem
checked={dobFilter.allowNull}
onCheckedChange={(checked) => {
dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => {
return {
...prevValue,
allowNull: checked
};
});
}}
onSelect={(e) => e.preventDefault()}
>
NULL
</DropdownMenu.CheckboxItem>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu>
);
};

const Toggles: React.FC<{ table: TanstackTable.Table<Subject> }> = ({ table }) => {
Expand Down Expand Up @@ -124,21 +255,21 @@ const Toggles: React.FC<{ table: TanstackTable.Table<Subject> }> = ({ table }) =
};

return (
<>
<div className="flex gap-3">
<Dialog open={isLookupOpen} onOpenChange={setIsLookupOpen}>
<Dialog.Trigger asChild>
<Button
className="gap-1"
className="gap-2"
data-spotlight-type="subject-lookup-search-button"
data-testid="subject-lookup-search-button"
id="subject-lookup-search-button"
variant="outline"
>
<UserSearchIcon />{' '}
{t({
en: 'Subject Lookup',
fr: 'Trouver un client'
})}
<UserSearchIcon style={{ strokeWidth: '2px' }} />
</Button>
</Dialog.Trigger>
<Dialog.Content data-spotlight-type="subject-lookup-modal" data-testid="datahub-subject-lookup-dialog">
Expand All @@ -148,20 +279,25 @@ const Toggles: React.FC<{ table: TanstackTable.Table<Subject> }> = ({ table }) =
<IdentificationForm onSubmit={(data) => void lookupSubject(data)} />
</Dialog.Content>
</Dialog>
<Filters table={table} />
<ActionDropdown
widthFull
className="min-w-48"
align="end"
className="font-medium"
data-spotlight-type="export-data-dropdown"
data-testid="datahub-export-dropdown"
options={['CSV', 'JSON', 'Excel']}
title={t('datahub.index.table.export')}
onSelection={handleExportSelection}
/>
</>
</div>
);
};

const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => {
const MasterDataTable: React.FC<{
data: Subject[];
onSelect: (subject: Subject) => void;
}> = ({ data, onSelect }) => {
const { t } = useTranslation();
const subjectIdDisplaySetting = useAppStore((store) => store.currentGroup?.settings.subjectIdDisplayLength);

Expand All @@ -175,13 +311,29 @@ const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => {
id: 'subjectId'
},
{
accessorFn: (subject) => (subject.dateOfBirth ? toBasicISOString(new Date(subject.dateOfBirth)) : 'NULL'),
accessorFn: (subject) => subject.dateOfBirth,
cell: (ctx) => {
const value = ctx.getValue() as Date | null | undefined;
return value ? toBasicISOString(value) : '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'),
id: 'date-of-birth'
},
{
accessorFn: (subject) => {
switch (subject.sex) {
accessorFn: (subject) => subject.sex ?? null,
cell: (ctx) => {
switch (ctx.getValue() as Sex) {
case 'FEMALE':
return t('core.identificationData.sex.female');
case 'MALE':
Expand All @@ -190,12 +342,31 @@ const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => {
return 'NULL';
}
},
filterFn: (row, id, filter: SexFilter) => {
return filter.includes(row.getValue(id));
},
header: t('core.identificationData.sex.label'),
id: 'sex'
}
]}
data={data}
data-testid="master-data-table"
initialState={{
columnFilters: [
{
id: 'sex',
value: ['MALE', 'FEMALE', null] satisfies SexFilter
},
{
id: 'date-of-birth',
value: {
allowNull: true,
max: null,
min: null
} satisfies DateFilter
}
]
}}
rowActions={[
{
label: t({ en: 'View', fr: 'Voir' }),
Expand Down
Loading