Skip to content

Commit e498899

Browse files
authored
Merge pull request #1275 from joshunrau/table-filters
feat: add ability to filter master data table by sex and date of birth
2 parents 6340e3c + 49780f3 commit e498899

File tree

1 file changed

+186
-15
lines changed

1 file changed

+186
-15
lines changed

apps/web/src/routes/_app/datahub/index.tsx

Lines changed: 186 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
import React, { useState } from 'react';
22

33
import { toBasicISOString } from '@douglasneuroinformatics/libjs';
4-
import { ActionDropdown, Button, DataTable, Dialog, Heading } from '@douglasneuroinformatics/libui/components';
4+
import {
5+
ActionDropdown,
6+
Button,
7+
DataTable,
8+
Dialog,
9+
DropdownMenu,
10+
Heading
11+
} from '@douglasneuroinformatics/libui/components';
512
import type { TanstackTable } from '@douglasneuroinformatics/libui/components';
613
import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks';
714
import type { InstrumentRecordsExport } from '@opendatacapture/schemas/instrument-records';
8-
import type { Subject } from '@opendatacapture/schemas/subject';
15+
import type { Sex, Subject } from '@opendatacapture/schemas/subject';
916
import { removeSubjectIdScope } from '@opendatacapture/subject-utils';
1017
import { createFileRoute, useNavigate } from '@tanstack/react-router';
1118
import axios from 'axios';
12-
import { UserSearchIcon } from 'lucide-react';
19+
import { ChevronDownIcon, UserSearchIcon } from 'lucide-react';
1320
import { unpack } from 'msgpackr/unpack';
1421
import { unparse } from 'papaparse';
1522

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

22-
type MasterDataTableProps = {
23-
data: Subject[];
24-
onSelect: (subject: Subject) => void;
29+
type DateFilter = {
30+
allowNull: boolean;
31+
max: Date | null;
32+
min: Date | null;
33+
};
34+
35+
type SexFilter = (null | Sex)[];
36+
37+
const Filters: React.FC<{ table: TanstackTable.Table<Subject> }> = ({ table }) => {
38+
const { t } = useTranslation();
39+
40+
const [isOpen, setIsOpen] = useState(false);
41+
42+
const columns = table.getAllColumns();
43+
44+
const dobColumn = columns.find((column) => column.id === 'date-of-birth')!;
45+
const dobFilter = dobColumn.getFilterValue() as DateFilter;
46+
47+
const sexColumn = columns.find((column) => column.id === 'sex')!;
48+
const sexFilter = sexColumn.getFilterValue() as SexFilter;
49+
50+
return (
51+
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
52+
<DropdownMenu.Trigger asChild>
53+
<Button className="flex items-center justify-between gap-2" variant="outline">
54+
{t({ en: 'Filters', fr: 'Filtres' })}
55+
<ChevronDownIcon className="opacity-50" />
56+
</Button>
57+
</DropdownMenu.Trigger>
58+
<DropdownMenu.Content align="end" className="w-56">
59+
<DropdownMenu.Label>{t('core.identificationData.sex.label')}</DropdownMenu.Label>
60+
<DropdownMenu.Group>
61+
<DropdownMenu.CheckboxItem
62+
checked={sexFilter.includes('MALE')}
63+
onCheckedChange={(checked) => {
64+
sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => {
65+
if (checked) {
66+
return [...prevValue, 'MALE'];
67+
}
68+
return prevValue.filter((item) => item !== 'MALE');
69+
});
70+
}}
71+
onSelect={(e) => e.preventDefault()}
72+
>
73+
{t('core.identificationData.sex.male')}
74+
</DropdownMenu.CheckboxItem>
75+
<DropdownMenu.CheckboxItem
76+
checked={sexFilter.includes('FEMALE')}
77+
onCheckedChange={(checked) => {
78+
sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => {
79+
if (checked) {
80+
return [...prevValue, 'FEMALE'];
81+
}
82+
return prevValue.filter((item) => item !== 'FEMALE');
83+
});
84+
}}
85+
onSelect={(e) => e.preventDefault()}
86+
>
87+
{t('core.identificationData.sex.female')}
88+
</DropdownMenu.CheckboxItem>
89+
<DropdownMenu.CheckboxItem
90+
checked={sexFilter.includes(null)}
91+
onCheckedChange={(checked) => {
92+
sexColumn.setFilterValue((prevValue: SexFilter): SexFilter => {
93+
if (checked) {
94+
return [...prevValue, null];
95+
}
96+
return prevValue.filter((item) => item !== null);
97+
});
98+
}}
99+
onSelect={(e) => e.preventDefault()}
100+
>
101+
NULL
102+
</DropdownMenu.CheckboxItem>
103+
</DropdownMenu.Group>
104+
<DropdownMenu.Label>{t('core.identificationData.dateOfBirth.label')}</DropdownMenu.Label>
105+
<DropdownMenu.Group>
106+
<div className="rounded-xs relative flex items-center justify-between gap-1 px-2 pb-1 pt-1.5 text-sm transition-colors">
107+
<span className="pb-1">Min:</span>
108+
<input
109+
className="text-muted-foreground pointer-events-auto rounded-sm border-b pb-0.5"
110+
type="date"
111+
value={dobFilter.min ? toBasicISOString(dobFilter.min) : ''}
112+
onChange={(event) => {
113+
dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => {
114+
return {
115+
...prevValue,
116+
min: event.target.valueAsDate
117+
};
118+
});
119+
}}
120+
/>
121+
</div>
122+
<div className="rounded-xs relative flex items-center justify-between gap-1 px-2 pb-1 pt-1.5 text-sm transition-colors">
123+
<span className="pb-1">Max:</span>
124+
<input
125+
className="text-muted-foreground pointer-events-auto rounded-sm border-b pb-0.5"
126+
type="date"
127+
value={dobFilter.max ? toBasicISOString(dobFilter.max) : ''}
128+
onChange={(event) => {
129+
dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => {
130+
return {
131+
...prevValue,
132+
max: event.target.valueAsDate
133+
};
134+
});
135+
}}
136+
/>
137+
</div>
138+
<DropdownMenu.CheckboxItem
139+
checked={dobFilter.allowNull}
140+
onCheckedChange={(checked) => {
141+
dobColumn.setFilterValue((prevValue: DateFilter): DateFilter => {
142+
return {
143+
...prevValue,
144+
allowNull: checked
145+
};
146+
});
147+
}}
148+
onSelect={(e) => e.preventDefault()}
149+
>
150+
NULL
151+
</DropdownMenu.CheckboxItem>
152+
</DropdownMenu.Group>
153+
</DropdownMenu.Content>
154+
</DropdownMenu>
155+
);
25156
};
26157

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

126257
return (
127-
<>
258+
<div className="flex gap-3">
128259
<Dialog open={isLookupOpen} onOpenChange={setIsLookupOpen}>
129260
<Dialog.Trigger asChild>
130261
<Button
131-
className="gap-1"
262+
className="gap-2"
132263
data-spotlight-type="subject-lookup-search-button"
133264
data-testid="subject-lookup-search-button"
134265
id="subject-lookup-search-button"
135266
variant="outline"
136267
>
137-
<UserSearchIcon />{' '}
138268
{t({
139269
en: 'Subject Lookup',
140270
fr: 'Trouver un client'
141271
})}
272+
<UserSearchIcon style={{ strokeWidth: '2px' }} />
142273
</Button>
143274
</Dialog.Trigger>
144275
<Dialog.Content data-spotlight-type="subject-lookup-modal" data-testid="datahub-subject-lookup-dialog">
@@ -148,20 +279,25 @@ const Toggles: React.FC<{ table: TanstackTable.Table<Subject> }> = ({ table }) =
148279
<IdentificationForm onSubmit={(data) => void lookupSubject(data)} />
149280
</Dialog.Content>
150281
</Dialog>
282+
<Filters table={table} />
151283
<ActionDropdown
152284
widthFull
153-
className="min-w-48"
285+
align="end"
286+
className="font-medium"
154287
data-spotlight-type="export-data-dropdown"
155288
data-testid="datahub-export-dropdown"
156289
options={['CSV', 'JSON', 'Excel']}
157290
title={t('datahub.index.table.export')}
158291
onSelection={handleExportSelection}
159292
/>
160-
</>
293+
</div>
161294
);
162295
};
163296

164-
const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => {
297+
const MasterDataTable: React.FC<{
298+
data: Subject[];
299+
onSelect: (subject: Subject) => void;
300+
}> = ({ data, onSelect }) => {
165301
const { t } = useTranslation();
166302
const subjectIdDisplaySetting = useAppStore((store) => store.currentGroup?.settings.subjectIdDisplayLength);
167303

@@ -175,13 +311,29 @@ const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => {
175311
id: 'subjectId'
176312
},
177313
{
178-
accessorFn: (subject) => (subject.dateOfBirth ? toBasicISOString(new Date(subject.dateOfBirth)) : 'NULL'),
314+
accessorFn: (subject) => subject.dateOfBirth,
315+
cell: (ctx) => {
316+
const value = ctx.getValue() as Date | null | undefined;
317+
return value ? toBasicISOString(value) : 'NULL';
318+
},
319+
filterFn: (row, id, filter: DateFilter) => {
320+
const value = row.getValue(id);
321+
if (!value) {
322+
return filter.allowNull;
323+
} else if (filter.max && value > filter.max) {
324+
return false;
325+
} else if (filter.min && value < filter.min) {
326+
return false;
327+
}
328+
return true;
329+
},
179330
header: t('core.identificationData.dateOfBirth.label'),
180331
id: 'date-of-birth'
181332
},
182333
{
183-
accessorFn: (subject) => {
184-
switch (subject.sex) {
334+
accessorFn: (subject) => subject.sex ?? null,
335+
cell: (ctx) => {
336+
switch (ctx.getValue() as Sex) {
185337
case 'FEMALE':
186338
return t('core.identificationData.sex.female');
187339
case 'MALE':
@@ -190,12 +342,31 @@ const MasterDataTable = ({ data, onSelect }: MasterDataTableProps) => {
190342
return 'NULL';
191343
}
192344
},
345+
filterFn: (row, id, filter: SexFilter) => {
346+
return filter.includes(row.getValue(id));
347+
},
193348
header: t('core.identificationData.sex.label'),
194349
id: 'sex'
195350
}
196351
]}
197352
data={data}
198353
data-testid="master-data-table"
354+
initialState={{
355+
columnFilters: [
356+
{
357+
id: 'sex',
358+
value: ['MALE', 'FEMALE', null] satisfies SexFilter
359+
},
360+
{
361+
id: 'date-of-birth',
362+
value: {
363+
allowNull: true,
364+
max: null,
365+
min: null
366+
} satisfies DateFilter
367+
}
368+
]
369+
}}
199370
rowActions={[
200371
{
201372
label: t({ en: 'View', fr: 'Voir' }),

0 commit comments

Comments
 (0)