Skip to content

Commit 50e4dc7

Browse files
authored
Merge pull request #1210 from david-roper/subject-table-download
Subject table download
2 parents f306b74 + 72f323b commit 50e4dc7

File tree

8 files changed

+5501
-3358
lines changed

8 files changed

+5501
-3358
lines changed

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@
6363
"@tailwindcss/vite": "catalog:",
6464
"@tanstack/react-query-devtools": "^5.83.0",
6565
"@tanstack/router-plugin": "^1.127.3",
66+
"@testing-library/dom": "^10.4.0",
67+
"@testing-library/react": "16.2.0",
6668
"@vitejs/plugin-react-swc": "^3.9.0",
6769
"happy-dom": "catalog:",
6870
"tailwindcss": "catalog:",

apps/web/src/__mocks__/zustand.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { act } from '@testing-library/react';
2+
import { afterEach, vi } from 'vitest';
3+
import * as zustand from 'zustand';
4+
import type { StateCreator, StoreApi, UseBoundStore } from 'zustand';
5+
6+
const { create: zCreate, createStore: zCreateStore } = await vi.importActual<typeof zustand>('zustand');
7+
8+
// a variable to hold reset functions for all stores declared in the app
9+
const STORE_RESET_FUNCTIONS = new Set<() => void>();
10+
11+
function createUncurried<T>(stateCreator: StateCreator<T>): UseBoundStore<StoreApi<T>> {
12+
const store = zCreate(stateCreator);
13+
const initialState = store.getInitialState();
14+
STORE_RESET_FUNCTIONS.add(() => {
15+
store.setState(initialState, true);
16+
});
17+
return store;
18+
}
19+
20+
function createStoreUncurried<T>(stateCreator: StateCreator<T>): StoreApi<T> {
21+
const store = zCreateStore(stateCreator);
22+
const initialState = store.getInitialState();
23+
STORE_RESET_FUNCTIONS.add(() => {
24+
store.setState(initialState, true);
25+
});
26+
return store;
27+
}
28+
29+
afterEach(() => {
30+
act(() => {
31+
STORE_RESET_FUNCTIONS.forEach((resetFn) => {
32+
resetFn();
33+
});
34+
STORE_RESET_FUNCTIONS.clear();
35+
});
36+
});
37+
38+
export function create<T>(stateCreator: StateCreator<T>): UseBoundStore<StoreApi<T>> {
39+
if (typeof stateCreator === 'function') {
40+
return createUncurried(stateCreator);
41+
}
42+
return createUncurried as unknown as UseBoundStore<StoreApi<T>>;
43+
}
44+
45+
export function createStore<T>(stateCreator: StateCreator<T>): StoreApi<T> {
46+
if (typeof stateCreator === 'function') {
47+
return createStoreUncurried(stateCreator);
48+
}
49+
return createStoreUncurried as unknown as StoreApi<T>;
50+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { toBasicISOString } from '@douglasneuroinformatics/libjs';
2+
import { act, renderHook } from '@testing-library/react';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { useInstrumentVisualization } from '../useInstrumentVisualization';
6+
7+
const mockUseInstrument = vi.hoisted(() =>
8+
vi.fn(() => ({
9+
internal: {
10+
edition: 1,
11+
name: 'test'
12+
}
13+
}))
14+
);
15+
16+
const mockStore = {
17+
currentGroup: { id: 'testGroupId' },
18+
currentUser: { username: 'testUser' }
19+
};
20+
21+
const mockDownloadFn = vi.fn();
22+
23+
const mockInfoQuery = {
24+
data: []
25+
};
26+
27+
const FIXED_TEST_DATE = new Date('2025-04-30T12:00:00Z');
28+
const mockInstrumentRecords = {
29+
data: [
30+
{
31+
computedMeasures: {},
32+
data: { someValue: 'abc' },
33+
date: FIXED_TEST_DATE
34+
}
35+
]
36+
};
37+
38+
vi.mock('@/hooks/useInstrument', () => ({
39+
useInstrument: mockUseInstrument
40+
}));
41+
42+
vi.mock('@/store', () => ({
43+
useAppStore: vi.fn((selector) => selector(mockStore))
44+
}));
45+
46+
vi.mock('@douglasneuroinformatics/libui/hooks', () => ({
47+
useDownload: vi.fn(() => mockDownloadFn),
48+
useNotificationsStore: () => ({ addNotification: vi.fn() }),
49+
useTranslation: () => ({ t: vi.fn((key) => key) })
50+
}));
51+
52+
vi.mock('@/hooks/useInstrumentInfoQuery', () => ({
53+
useInstrumentInfoQuery: () => mockInfoQuery
54+
}));
55+
56+
vi.mock('@/hooks/useInstrumentRecords', () => ({
57+
useInstrumentRecords: () => mockInstrumentRecords
58+
}));
59+
60+
describe('useInstrumentVisualization', () => {
61+
beforeEach(() => {
62+
vi.clearAllMocks();
63+
});
64+
65+
describe('CSV', () => {
66+
it('Should download', () => {
67+
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
68+
const { records } = result.current;
69+
act(() => result.current.dl('CSV'));
70+
expect(records).toBeDefined();
71+
expect(mockDownloadFn).toHaveBeenCalledTimes(1);
72+
const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
73+
expect(filename).toContain('.csv');
74+
const csvContents = getContentFn();
75+
expect(csvContents).toMatch(`subjectId,Date,someValue\r\ntestId,${toBasicISOString(FIXED_TEST_DATE)},abc`);
76+
});
77+
});
78+
describe('TSV', () => {
79+
it('Should download', () => {
80+
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
81+
const { dl, records } = result.current;
82+
act(() => dl('TSV'));
83+
expect(records).toBeDefined();
84+
expect(mockDownloadFn).toHaveBeenCalledTimes(1);
85+
const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
86+
expect(filename).toContain('.tsv');
87+
const tsvContents = getContentFn();
88+
expect(tsvContents).toMatch(`subjectId\tDate\tsomeValue\r\ntestId\t${toBasicISOString(FIXED_TEST_DATE)}\tabc`);
89+
});
90+
});
91+
describe('CSV Long', () => {
92+
it('Should download', () => {
93+
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
94+
const { dl, records } = result.current;
95+
act(() => dl('CSV Long'));
96+
expect(records).toBeDefined();
97+
expect(mockDownloadFn).toHaveBeenCalledTimes(1);
98+
99+
const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
100+
expect(filename).toContain('.csv');
101+
const csvLongContents = getContentFn();
102+
expect(csvLongContents).toMatch(
103+
`Date,SubjectID,Value,Variable\r\n${toBasicISOString(FIXED_TEST_DATE)},testId,abc,someValue`
104+
);
105+
});
106+
});
107+
describe('TSV Long', () => {
108+
it('Should download', () => {
109+
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
110+
const { dl, records } = result.current;
111+
act(() => dl('TSV Long'));
112+
expect(records).toBeDefined();
113+
expect(mockDownloadFn).toHaveBeenCalledTimes(1);
114+
115+
const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
116+
expect(filename).toMatch('.tsv');
117+
const tsvLongContents = getContentFn();
118+
expect(tsvLongContents).toMatch(
119+
`Date\tSubjectID\tValue\tVariable\r\n${toBasicISOString(FIXED_TEST_DATE)}\ttestId\tabc\tsomeValue`
120+
);
121+
});
122+
});
123+
describe('JSON', () => {
124+
it('Should download', async () => {
125+
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
126+
const { dl, records } = result.current;
127+
act(() => dl('JSON'));
128+
expect(records).toBeDefined();
129+
expect(mockDownloadFn).toHaveBeenCalledTimes(1);
130+
131+
const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
132+
expect(filename).toMatch('.json');
133+
const jsonContents = await getContentFn();
134+
expect(jsonContents).toContain('"someValue": "abc"');
135+
expect(jsonContents).toContain('"subjectID": "testId"');
136+
});
137+
});
138+
});

apps/web/src/hooks/useInstrumentVisualization.ts

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { useEffect, useMemo, useState } from 'react';
22

3+
import { toBasicISOString } from '@douglasneuroinformatics/libjs';
34
import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks';
45
import type { AnyUnilingualScalarInstrument, InstrumentKind } from '@opendatacapture/runtime-core';
56
import { omit } from 'lodash-es';
7+
import { unparse } from 'papaparse';
68

79
import { useInstrument } from '@/hooks/useInstrument';
810
import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery';
@@ -50,7 +52,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio
5052
}
5153
});
5254

53-
const dl = (option: 'JSON' | 'TSV') => {
55+
const dl = (option: 'CSV' | 'CSV Long' | 'JSON' | 'TSV' | 'TSV Long') => {
5456
if (!instrument) {
5557
notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' });
5658
return;
@@ -63,24 +65,113 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio
6365
instrument.internal.edition
6466
}_${new Date().toISOString()}`;
6567

66-
const exportRecords = records.map((record) => omit(record, ['__date__', '__time__']));
68+
const exportRecords = records.map((record) => omit(record, ['__time__']));
69+
70+
const makeWideRows = () => {
71+
const columnNames = Object.keys(exportRecords[0]!);
72+
return exportRecords.map((item) => {
73+
const obj: { [key: string]: any } = { subjectId: params.subjectId };
74+
for (const key of columnNames) {
75+
const val = item[key];
76+
if (key === '__date__') {
77+
obj.Date = toBasicISOString(val as Date);
78+
continue;
79+
}
80+
obj[key] = typeof val === 'object' ? JSON.stringify(val) : val;
81+
}
82+
return obj;
83+
});
84+
};
85+
86+
const makeLongRows = () => {
87+
const longRecord: { [key: string]: any }[] = [];
88+
89+
exportRecords.forEach((item) => {
90+
let date: Date;
91+
92+
Object.entries(item).forEach(([objKey, objVal]) => {
93+
if (objKey === '__date__') {
94+
date = objVal as Date;
95+
return;
96+
}
97+
98+
if (Array.isArray(objVal)) {
99+
objVal.forEach((arrayItem) => {
100+
Object.entries(arrayItem as object).forEach(([arrKey, arrItem]) => {
101+
longRecord.push({
102+
Date: toBasicISOString(date),
103+
SubjectID: params.subjectId,
104+
Variable: `${objKey}-${arrKey}`,
105+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, perfectionist/sort-objects
106+
Value: arrItem
107+
});
108+
});
109+
});
110+
} else {
111+
longRecord.push({
112+
Date: toBasicISOString(date),
113+
SubjectID: params.subjectId,
114+
Value: objVal,
115+
Variable: objKey
116+
});
117+
}
118+
});
119+
});
120+
121+
return longRecord;
122+
};
123+
124+
const parseHelper = (rows: unknown[], delimiter: string) => {
125+
return unparse(rows, {
126+
delimiter: delimiter,
127+
escapeChar: '"',
128+
header: true,
129+
quoteChar: '"',
130+
quotes: false,
131+
skipEmptyLines: true
132+
});
133+
};
67134

68135
switch (option) {
69-
case 'JSON':
136+
case 'CSV':
137+
void download(`${baseFilename}.csv`, () => {
138+
const rows = makeWideRows();
139+
const csv = parseHelper(rows, ',');
140+
141+
return csv;
142+
});
143+
break;
144+
case 'CSV Long': {
145+
void download(`${baseFilename}.csv`, () => {
146+
const rows = makeLongRows();
147+
const csv = parseHelper(rows, ',');
148+
return csv;
149+
});
150+
break;
151+
}
152+
case 'JSON': {
153+
exportRecords.map((item) => {
154+
item.subjectID = params.subjectId;
155+
});
70156
void download(`${baseFilename}.json`, () => Promise.resolve(JSON.stringify(exportRecords, null, 2)));
71157
break;
158+
}
72159
case 'TSV':
73160
void download(`${baseFilename}.tsv`, () => {
74-
const columnNames = Object.keys(exportRecords[0]!).join('\t');
75-
const rows = exportRecords
76-
.map((item) =>
77-
Object.values(item)
78-
.map((val) => JSON.stringify(val))
79-
.join('\t')
80-
)
81-
.join('\n');
82-
return columnNames + '\n' + rows;
161+
const rows = makeWideRows();
162+
const tsv = parseHelper(rows, '\t');
163+
164+
return tsv;
83165
});
166+
break;
167+
case 'TSV Long':
168+
void download(`${baseFilename}.tsv`, () => {
169+
const rows = makeLongRows();
170+
const tsv = parseHelper(rows, '\t');
171+
172+
return tsv;
173+
});
174+
break;
84175
}
85176
};
86177

apps/web/src/routes/_app/datahub/$subjectId/table.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const RouteComponent = () => {
3838
widthFull
3939
data-spotlight-type="export-data-dropdown"
4040
disabled={!instrumentId}
41-
options={['TSV', 'JSON']}
41+
options={['TSV', 'TSV Long', 'JSON', 'CSV', 'CSV Long']}
4242
title={t('core.download')}
4343
triggerClassName="min-w-32"
4444
onSelection={dl}

apps/web/vitest.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import * as path from 'node:path';
2+
13
import { mergeConfig } from 'vitest/config';
24

35
import baseConfig from '../../vitest.config';
46

57
export default mergeConfig(baseConfig, {
8+
resolve: {
9+
alias: {
10+
'@': path.resolve(import.meta.dirname, 'src')
11+
}
12+
},
613
test: {
714
environment: 'happy-dom',
815
root: import.meta.dirname

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
"@swc/cli": "^0.6.0",
4242
"@swc/core": "^1.10.9",
4343
"@swc/helpers": "^0.5.15",
44-
"@types/github-script": "https://github.com/actions/github-script/archive/refs/tags/v7.0.1.tar.gz",
4544
"@types/js-yaml": "^4.0.9",
4645
"@types/node": "22.x",
4746
"@vitest/browser": "^3.2.4",

0 commit comments

Comments
 (0)