Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1847144
chore: adding csv as an option to subject table
david-roper Oct 6, 2025
51cc8ff
feat: add a csv option to subject table download
david-roper Oct 7, 2025
fe309bc
refactor: use papa parse to interpret tsv data
david-roper Oct 7, 2025
27a15a2
feat: add subject id to exports
david-roper Oct 7, 2025
2dc7552
feat: add long options create makeWideRows helper function for wide o…
david-roper Oct 9, 2025
883f178
feat: add long cases
david-roper Oct 9, 2025
b421716
feat: add create long rows function
david-roper Oct 9, 2025
4c0d622
feat: create parser helper function
david-roper Oct 10, 2025
57b70a9
feat: change makeLongRows to for each method instead of map
david-roper Oct 10, 2025
12ae11e
chore: remove console logs
david-roper Oct 10, 2025
6aa18a8
test: fix tests from linter errors
david-roper Oct 10, 2025
199a4a0
test: add test file
david-roper Oct 10, 2025
bb98e7a
test: start dl tests
david-roper Oct 10, 2025
96f6f4a
test: start tests for table uplaod
david-roper Oct 14, 2025
893ba0c
chore: add type to long record, disable perfectionist for long record…
david-roper Oct 15, 2025
7999b71
chore: add react testing library
david-roper Oct 15, 2025
7126328
chore: update vitest config
david-roper Oct 15, 2025
b6c29b1
chore: add zustand mock file
david-roper Oct 15, 2025
8c753ff
test: add mocks for all hooks and react components
david-roper Oct 15, 2025
ddfe864
test: make mocks return test data
david-roper Oct 15, 2025
5940ce0
chore: fix download function
david-roper Oct 15, 2025
299fc8d
test: add expected call for csv in filename
david-roper Oct 15, 2025
b6c5bfa
test: add mock isoString date, match csv contents
david-roper Oct 15, 2025
b607bd8
test: create tests to confirm contents of downloads
david-roper Oct 16, 2025
fbfbf94
fix: remove github type workflow package
david-roper Oct 16, 2025
4d6789a
chore: update test packages and lock file
david-roper Oct 16, 2025
5ccf057
chore: update web test packages
david-roper Oct 16, 2025
a0fa764
chore: small code rabbit fixes to zustand.ts and dl test
david-roper Oct 16, 2025
1766ace
test: remove unused spyon method
david-roper Oct 16, 2025
8a685b8
Update apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts
david-roper Oct 16, 2025
c011c01
refactor: refactor mock notification and translation
david-roper Oct 16, 2025
cb92937
rename describe test suite
david-roper Oct 17, 2025
057d54c
test: refactor to use renderhook, set records with useInstrumentRecor…
david-roper Oct 17, 2025
a245fe5
test: use toBasicISOString method to compare dates of output
david-roper Oct 17, 2025
83b1679
test: code rabbit change for useQuery, remove unused date
david-roper Oct 17, 2025
f06e4d4
test: add fix test date for all date tests
david-roper Oct 17, 2025
72f323b
fix: resolve linter issues and update lockfile
david-roper Oct 17, 2025
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
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
"@tailwindcss/vite": "catalog:",
"@tanstack/react-query-devtools": "^5.83.0",
"@tanstack/router-plugin": "^1.127.3",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "16.2.0",
"@vitejs/plugin-react-swc": "^3.9.0",
"happy-dom": "catalog:",
"tailwindcss": "catalog:",
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/__mocks__/zustand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { act } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
import * as zustand from 'zustand';
import type { StateCreator, StoreApi, UseBoundStore } from 'zustand';

const { create: zCreate, createStore: zCreateStore } = await vi.importActual<typeof zustand>('zustand');

// a variable to hold reset functions for all stores declared in the app
const STORE_RESET_FUNCTIONS = new Set<() => void>();

function createUncurried<T>(stateCreator: StateCreator<T>): UseBoundStore<StoreApi<T>> {
const store = zCreate(stateCreator);
const initialState = store.getInitialState();
STORE_RESET_FUNCTIONS.add(() => {
store.setState(initialState, true);
});
return store;
}

function createStoreUncurried<T>(stateCreator: StateCreator<T>): StoreApi<T> {
const store = zCreateStore(stateCreator);
const initialState = store.getInitialState();
STORE_RESET_FUNCTIONS.add(() => {
store.setState(initialState, true);
});
return store;
}

afterEach(() => {
act(() => {
STORE_RESET_FUNCTIONS.forEach((resetFn) => {
resetFn();
});
STORE_RESET_FUNCTIONS.clear();
});
});

export function create<T>(stateCreator: StateCreator<T>): UseBoundStore<StoreApi<T>> {
if (typeof stateCreator === 'function') {
return createUncurried(stateCreator);
}
return createUncurried as unknown as UseBoundStore<StoreApi<T>>;
}

export function createStore<T>(stateCreator: StateCreator<T>): StoreApi<T> {
if (typeof stateCreator === 'function') {
return createStoreUncurried(stateCreator);
}
return createStoreUncurried as unknown as StoreApi<T>;
}
138 changes: 138 additions & 0 deletions apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { toBasicISOString } from '@douglasneuroinformatics/libjs';
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { useInstrumentVisualization } from '../useInstrumentVisualization';

const mockUseInstrument = vi.hoisted(() =>
vi.fn(() => ({
internal: {
edition: 1,
name: 'test'
}
}))
);

const mockStore = {
currentGroup: { id: 'testGroupId' },
currentUser: { username: 'testUser' }
};

const mockDownloadFn = vi.fn();

const mockInfoQuery = {
data: []
};

const FIXED_TEST_DATE = new Date('2025-04-30T12:00:00Z');
const mockInstrumentRecords = {
data: [
{
computedMeasures: {},
data: { someValue: 'abc' },
date: FIXED_TEST_DATE
}
]
};

vi.mock('@/hooks/useInstrument', () => ({
useInstrument: mockUseInstrument
}));

vi.mock('@/store', () => ({
useAppStore: vi.fn((selector) => selector(mockStore))
}));

vi.mock('@douglasneuroinformatics/libui/hooks', () => ({
useDownload: vi.fn(() => mockDownloadFn),
useNotificationsStore: () => ({ addNotification: vi.fn() }),
useTranslation: () => ({ t: vi.fn((key) => key) })
}));

vi.mock('@/hooks/useInstrumentInfoQuery', () => ({
useInstrumentInfoQuery: () => mockInfoQuery
}));

vi.mock('@/hooks/useInstrumentRecords', () => ({
useInstrumentRecords: () => mockInstrumentRecords
}));

describe('useInstrumentVisualization', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('CSV', () => {
it('Should download', () => {
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
const { records } = result.current;
act(() => result.current.dl('CSV'));
expect(records).toBeDefined();
expect(mockDownloadFn).toHaveBeenCalledTimes(1);
const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
expect(filename).toContain('.csv');
const csvContents = getContentFn();
expect(csvContents).toMatch(`subjectId,Date,someValue\r\ntestId,${toBasicISOString(FIXED_TEST_DATE)},abc`);
});
});
describe('TSV', () => {
it('Should download', () => {
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
const { dl, records } = result.current;
act(() => dl('TSV'));
expect(records).toBeDefined();
expect(mockDownloadFn).toHaveBeenCalledTimes(1);
const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
expect(filename).toContain('.tsv');
const tsvContents = getContentFn();
expect(tsvContents).toMatch(`subjectId\tDate\tsomeValue\r\ntestId\t${toBasicISOString(FIXED_TEST_DATE)}\tabc`);
});
});
describe('CSV Long', () => {
it('Should download', () => {
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
const { dl, records } = result.current;
act(() => dl('CSV Long'));
expect(records).toBeDefined();
expect(mockDownloadFn).toHaveBeenCalledTimes(1);

const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
expect(filename).toContain('.csv');
const csvLongContents = getContentFn();
expect(csvLongContents).toMatch(
`Date,SubjectID,Value,Variable\r\n${toBasicISOString(FIXED_TEST_DATE)},testId,abc,someValue`
);
});
});
describe('TSV Long', () => {
it('Should download', () => {
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
const { dl, records } = result.current;
act(() => dl('TSV Long'));
expect(records).toBeDefined();
expect(mockDownloadFn).toHaveBeenCalledTimes(1);

const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
expect(filename).toMatch('.tsv');
const tsvLongContents = getContentFn();
expect(tsvLongContents).toMatch(
`Date\tSubjectID\tValue\tVariable\r\n${toBasicISOString(FIXED_TEST_DATE)}\ttestId\tabc\tsomeValue`
);
});
});
describe('JSON', () => {
it('Should download', async () => {
const { result } = renderHook(() => useInstrumentVisualization({ params: { subjectId: 'testId' } }));
const { dl, records } = result.current;
act(() => dl('JSON'));
expect(records).toBeDefined();
expect(mockDownloadFn).toHaveBeenCalledTimes(1);

const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? [];
expect(filename).toMatch('.json');
const jsonContents = await getContentFn();
expect(jsonContents).toContain('"someValue": "abc"');
expect(jsonContents).toContain('"subjectID": "testId"');
});
});
});
115 changes: 103 additions & 12 deletions apps/web/src/hooks/useInstrumentVisualization.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from 'react';

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

import { useInstrument } from '@/hooks/useInstrument';
import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery';
Expand Down Expand Up @@ -50,7 +52,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio
}
});

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

const exportRecords = records.map((record) => omit(record, ['__date__', '__time__']));
const exportRecords = records.map((record) => omit(record, ['__time__']));

const makeWideRows = () => {
const columnNames = Object.keys(exportRecords[0]!);
return exportRecords.map((item) => {
const obj: { [key: string]: any } = { subjectId: params.subjectId };
for (const key of columnNames) {
const val = item[key];
if (key === '__date__') {
obj.Date = toBasicISOString(val as Date);
continue;
}
obj[key] = typeof val === 'object' ? JSON.stringify(val) : val;
}
return obj;
});
};

const makeLongRows = () => {
const longRecord: { [key: string]: any }[] = [];

exportRecords.forEach((item) => {
let date: Date;

Object.entries(item).forEach(([objKey, objVal]) => {
if (objKey === '__date__') {
date = objVal as Date;
return;
}

if (Array.isArray(objVal)) {
objVal.forEach((arrayItem) => {
Object.entries(arrayItem as object).forEach(([arrKey, arrItem]) => {
longRecord.push({
Date: toBasicISOString(date),
SubjectID: params.subjectId,
Variable: `${objKey}-${arrKey}`,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, perfectionist/sort-objects
Value: arrItem
});
});
});
} else {
longRecord.push({
Date: toBasicISOString(date),
SubjectID: params.subjectId,
Value: objVal,
Variable: objKey
});
}
});
});

return longRecord;
};

const parseHelper = (rows: unknown[], delimiter: string) => {
return unparse(rows, {
delimiter: delimiter,
escapeChar: '"',
header: true,
quoteChar: '"',
quotes: false,
skipEmptyLines: true
});
};

switch (option) {
case 'JSON':
case 'CSV':
void download(`${baseFilename}.csv`, () => {
const rows = makeWideRows();
const csv = parseHelper(rows, ',');

return csv;
});
break;
case 'CSV Long': {
void download(`${baseFilename}.csv`, () => {
const rows = makeLongRows();
const csv = parseHelper(rows, ',');
return csv;
});
break;
}
case 'JSON': {
exportRecords.map((item) => {
item.subjectID = params.subjectId;
});
void download(`${baseFilename}.json`, () => Promise.resolve(JSON.stringify(exportRecords, null, 2)));
break;
}
case 'TSV':
void download(`${baseFilename}.tsv`, () => {
const columnNames = Object.keys(exportRecords[0]!).join('\t');
const rows = exportRecords
.map((item) =>
Object.values(item)
.map((val) => JSON.stringify(val))
.join('\t')
)
.join('\n');
return columnNames + '\n' + rows;
const rows = makeWideRows();
const tsv = parseHelper(rows, '\t');

return tsv;
});
break;
case 'TSV Long':
void download(`${baseFilename}.tsv`, () => {
const rows = makeLongRows();
const tsv = parseHelper(rows, '\t');

return tsv;
});
break;
}
};

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/routes/_app/datahub/$subjectId/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const RouteComponent = () => {
widthFull
data-spotlight-type="export-data-dropdown"
disabled={!instrumentId}
options={['TSV', 'JSON']}
options={['TSV', 'TSV Long', 'JSON', 'CSV', 'CSV Long']}
title={t('core.download')}
triggerClassName="min-w-32"
onSelection={dl}
Expand Down
7 changes: 7 additions & 0 deletions apps/web/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import * as path from 'node:path';

import { mergeConfig } from 'vitest/config';

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

export default mergeConfig(baseConfig, {
resolve: {
alias: {
'@': path.resolve(import.meta.dirname, 'src')
}
},
test: {
environment: 'happy-dom',
root: import.meta.dirname
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.9",
"@swc/helpers": "^0.5.15",
"@types/github-script": "https://github.com/actions/github-script/archive/refs/tags/v7.0.1.tar.gz",
"@types/js-yaml": "^4.0.9",
"@types/node": "22.x",
"@vitest/browser": "^3.2.4",
Expand Down
Loading