feat(export): modal gains assignable axes + group-by control
This commit is contained in:
@@ -2,9 +2,12 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import { experimentsApi } from '../api/client';
|
import { experimentsApi } from '../api/client';
|
||||||
import Button from './ui/Button';
|
import Button from './ui/Button';
|
||||||
import Alert from './ui/Alert';
|
import Alert from './ui/Alert';
|
||||||
import { listExportableParams, buildMatrix, toCSV, csvFilename } from '../lib/dataExport';
|
import {
|
||||||
|
EXPORT_DIMENSIONS, dimLabel, otherDimension,
|
||||||
|
listDimensionMembers, buildMatrix, toCSV, csvFilename,
|
||||||
|
} from '../lib/dataExport';
|
||||||
|
|
||||||
const GROUP_LABELS = { metrics: 'Session metrics', daily: 'Daily record fields' };
|
const PARAM_GROUP_LABELS = { metrics: 'Session metrics', daily: 'Daily record fields' };
|
||||||
|
|
||||||
function triggerDownload(filename, text) {
|
function triggerDownload(filename, text) {
|
||||||
const blob = new Blob([text], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob([text], { type: 'text/csv;charset=utf-8;' });
|
||||||
@@ -18,11 +21,17 @@ function triggerDownload(filename, text) {
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExportDataModal({ experimentTitle, experimentId, dailyTemplate, animals, onClose }) {
|
export default function ExportDataModal({
|
||||||
|
experimentTitle, experimentId, dailyTemplate, animals, subjectTemplate = [], groupField = '__none__', onClose,
|
||||||
|
}) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [statuses, setStatuses] = useState([]);
|
const [statuses, setStatuses] = useState([]);
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
|
||||||
|
const [rowDim, setRowDim] = useState('date');
|
||||||
|
const [colDim, setColDim] = useState('subject');
|
||||||
|
const [pinnedId, setPinnedId] = useState(null);
|
||||||
|
const [groupBy, setGroupBy] = useState(groupField ?? '__none__');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -35,33 +44,42 @@ export default function ExportDataModal({ experimentTitle, experimentId, dailyTe
|
|||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, [experimentId]);
|
}, [experimentId]);
|
||||||
|
|
||||||
const params = useMemo(() => listExportableParams(dailyTemplate, statuses), [dailyTemplate, statuses]);
|
const pinnedDim = otherDimension(rowDim, colDim);
|
||||||
|
const subjectIsAxis = rowDim === 'subject' || colDim === 'subject';
|
||||||
|
const effGroup = subjectIsAxis ? groupBy : '__none__';
|
||||||
|
const ctx = useMemo(() => ({ statuses, animals, dailyTemplate }), [statuses, animals, dailyTemplate]);
|
||||||
|
|
||||||
// Derive the effective selection: the user's pick, else the first parameter.
|
// Members of the pinned dimension populate its value dropdown.
|
||||||
const effectiveId = selectedId ?? params[0]?.id ?? null;
|
const pinnedMembers = useMemo(
|
||||||
const selectedParam = params.find((p) => p.id === effectiveId) ?? null;
|
() => listDimensionMembers(pinnedDim, { ...ctx, groupField: effGroup }),
|
||||||
|
[pinnedDim, ctx, effGroup],
|
||||||
|
);
|
||||||
|
const effPinnedId = pinnedId ?? pinnedMembers[0]?.id ?? null;
|
||||||
|
const pinnedMember = pinnedMembers.find((m) => m.id === effPinnedId) ?? pinnedMembers[0] ?? null;
|
||||||
|
|
||||||
const matrix = useMemo(
|
const matrix = useMemo(
|
||||||
() => (selectedParam ? buildMatrix(statuses, animals, selectedParam) : { columns: [], rows: [] }),
|
() => (pinnedMember
|
||||||
[statuses, animals, selectedParam],
|
? buildMatrix({ rowDim, colDim, pinnedMember, groupField: effGroup }, ctx)
|
||||||
|
: { columns: [], rows: [], context: { label: '', value: '' }, corner: '', groupAxis: null }),
|
||||||
|
[rowDim, colDim, pinnedMember, effGroup, ctx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasData = matrix.rows.length > 0;
|
const hasData = matrix.rows.length > 0;
|
||||||
|
|
||||||
// Group params for <optgroup> rendering, preserving order.
|
// When the user changes an axis, keep the two distinct and reset the pinned pick.
|
||||||
const grouped = useMemo(() => {
|
function changeRowDim(next) {
|
||||||
const out = [];
|
setRowDim(next);
|
||||||
for (const p of params) {
|
if (colDim === next) setColDim(otherDimension(next, null) ?? EXPORT_DIMENSIONS.find((d) => d.dim !== next).dim);
|
||||||
let g = out.find((x) => x.group === p.group);
|
setPinnedId(null);
|
||||||
if (!g) { g = { group: p.group, items: [] }; out.push(g); }
|
}
|
||||||
g.items.push(p);
|
function changeColDim(next) {
|
||||||
}
|
setColDim(next);
|
||||||
return out;
|
if (rowDim === next) setRowDim(EXPORT_DIMENSIONS.find((d) => d.dim !== next).dim);
|
||||||
}, [params]);
|
setPinnedId(null);
|
||||||
|
}
|
||||||
|
|
||||||
function handleExport() {
|
function handleExport() {
|
||||||
if (!hasData || !selectedParam) return;
|
if (!hasData || !pinnedMember) return;
|
||||||
triggerDownload(csvFilename(experimentTitle, selectedParam.label), toCSV(matrix));
|
triggerDownload(csvFilename(experimentTitle, pinnedMember.label), toCSV(matrix));
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,38 +87,72 @@ export default function ExportDataModal({ experimentTitle, experimentId, dailyTe
|
|||||||
if (error) return (
|
if (error) return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Alert type="error" message={error} />
|
<Alert type="error" message={error} />
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end"><Button variant="secondary" onClick={onClose}>Close</Button></div>
|
||||||
<Button variant="secondary" onClick={onClose}>Close</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectCls = 'w-full border border-gray-200 rounded px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400';
|
||||||
|
const colOptions = EXPORT_DIMENSIONS.filter((d) => d.dim !== rowDim);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Export one parameter as a CSV file — each row is a date, each column is a subject.
|
Choose what goes on the rows and columns. The remaining dimension is fixed to one value, shown at the top of the file.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="export-rows" className="block text-xs font-medium text-gray-500 mb-1">Rows</label>
|
||||||
|
<select id="export-rows" value={rowDim} onChange={(e) => changeRowDim(e.target.value)} className={selectCls}>
|
||||||
|
{EXPORT_DIMENSIONS.map((d) => <option key={d.dim} value={d.dim}>{d.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="export-cols" className="block text-xs font-medium text-gray-500 mb-1">Columns</label>
|
||||||
|
<select id="export-cols" value={colDim} onChange={(e) => changeColDim(e.target.value)} className={selectCls}>
|
||||||
|
{colOptions.map((d) => <option key={d.dim} value={d.dim}>{d.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="export-param" className="block text-xs font-medium text-gray-500 mb-1">Parameter</label>
|
<label htmlFor="export-pinned" className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
Fixed: {dimLabel(pinnedDim)}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
id="export-param"
|
id="export-pinned"
|
||||||
value={effectiveId ?? ''}
|
value={effPinnedId ?? ''}
|
||||||
onChange={(e) => setSelectedId(e.target.value)}
|
onChange={(e) => setPinnedId(e.target.value)}
|
||||||
className="w-full border border-gray-200 rounded px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400"
|
className={selectCls}
|
||||||
>
|
>
|
||||||
{grouped.map((g) => (
|
{pinnedDim === 'parameter'
|
||||||
<optgroup key={g.group} label={GROUP_LABELS[g.group] ?? g.group}>
|
? groupParamMembers(pinnedMembers).map((g) => (
|
||||||
{g.items.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
<optgroup key={g.group} label={PARAM_GROUP_LABELS[g.group] ?? g.group}>
|
||||||
</optgroup>
|
{g.items.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||||
))}
|
</optgroup>
|
||||||
|
))
|
||||||
|
: pinnedMembers.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{subjectIsAxis && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="export-group" className="block text-xs font-medium text-gray-500 mb-1">Group by</label>
|
||||||
|
<select id="export-group" value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={selectCls}>
|
||||||
|
<option value="__none__">— None —</option>
|
||||||
|
<option value="__name__">Name</option>
|
||||||
|
<option value="__id__">Subject ID</option>
|
||||||
|
{subjectTemplate.filter((f) => f.active).map((f) => (
|
||||||
|
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
{hasData
|
{hasData
|
||||||
? `${matrix.rows.length} date${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${matrix.columns.length !== 1 ? 's' : ''}`
|
? `${matrix.rows.length} ${matrix.corner.toLowerCase()}${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} ${dimLabel(colDim).toLowerCase()}${matrix.columns.length !== 1 ? 's' : ''}`
|
||||||
: 'No data for this parameter yet.'}
|
: 'No data for this selection yet.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
@@ -110,3 +162,14 @@ export default function ExportDataModal({ experimentTitle, experimentId, dailyTe
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group parameter members by their .group for <optgroup> rendering, preserving order.
|
||||||
|
function groupParamMembers(members) {
|
||||||
|
const out = [];
|
||||||
|
for (const m of members) {
|
||||||
|
let g = out.find((x) => x.group === m.group);
|
||||||
|
if (!g) { g = { group: m.group, items: [] }; out.push(g); }
|
||||||
|
g.items.push(m);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
import ExportDataModal from '../src/components/ExportDataModal';
|
||||||
|
import * as client from '../src/api/client';
|
||||||
|
|
||||||
|
jest.mock('../src/api/client', () => ({
|
||||||
|
experimentsApi: { getDailyStatuses: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animals = [
|
||||||
|
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } },
|
||||||
|
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } },
|
||||||
|
];
|
||||||
|
const statuses = [
|
||||||
|
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 5 } },
|
||||||
|
{ animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 9 } },
|
||||||
|
];
|
||||||
|
const subjectTemplate = [{ fieldId: 'grp', label: 'Group', active: true }];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
client.experimentsApi.getDailyStatuses.mockResolvedValue(statuses);
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderModal(props = {}) {
|
||||||
|
return render(
|
||||||
|
<ExportDataModal
|
||||||
|
experimentId="exp-1"
|
||||||
|
experimentTitle="My Study"
|
||||||
|
dailyTemplate={[]}
|
||||||
|
animals={animals}
|
||||||
|
subjectTemplate={subjectTemplate}
|
||||||
|
groupField="__none__"
|
||||||
|
onClose={jest.fn()}
|
||||||
|
{...props}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ExportDataModal', () => {
|
||||||
|
it('defaults to Rows=Date, Columns=Subject and shows the preview', async () => {
|
||||||
|
renderModal();
|
||||||
|
expect(await screen.findByLabelText('Rows')).toHaveValue('date');
|
||||||
|
expect(screen.getByLabelText('Columns')).toHaveValue('subject');
|
||||||
|
await waitFor(() => expect(screen.getByText(/1 date.*2 subjects/i)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the Group by control until Subject is an axis', async () => {
|
||||||
|
renderModal();
|
||||||
|
await screen.findByLabelText('Rows');
|
||||||
|
// subject is a column by default -> Group by visible
|
||||||
|
expect(screen.getByLabelText('Group by')).toBeInTheDocument();
|
||||||
|
// move subject off both axes: Rows=Date, Columns=Parameter -> subject pinned
|
||||||
|
fireEvent.change(screen.getByLabelText('Columns'), { target: { value: 'parameter' } });
|
||||||
|
await waitFor(() => expect(screen.queryByLabelText('Group by')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps row and column dimensions distinct', async () => {
|
||||||
|
renderModal();
|
||||||
|
const rows = await screen.findByLabelText('Rows');
|
||||||
|
fireEvent.change(rows, { target: { value: 'subject' } }); // collides with column=subject
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('Columns')).not.toHaveValue('subject'));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user