diff --git a/frontend/src/components/ExportDataModal.jsx b/frontend/src/components/ExportDataModal.jsx
index d8cc000..c97eedc 100644
--- a/frontend/src/components/ExportDataModal.jsx
+++ b/frontend/src/components/ExportDataModal.jsx
@@ -2,10 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { experimentsApi } from '../api/client';
import Button from './ui/Button';
import Alert from './ui/Alert';
-import {
- EXPORT_DIMENSIONS, dimLabel, otherDimension,
- listDimensionMembers, buildMatrix, toCSV, csvFilename,
-} from '../lib/dataExport';
+import { listExportableParams, buildSubjectSeriesMatrix, toCSV, csvFilename } from '../lib/dataExport';
const PARAM_GROUP_LABELS = { metrics: 'Session metrics', daily: 'Daily record fields' };
@@ -28,9 +25,8 @@ export default function ExportDataModal({
const [error, setError] = useState(null);
const [statuses, setStatuses] = useState([]);
- const [rowDim, setRowDim] = useState('date');
- const [colDim, setColDim] = useState('subject');
- const [pinnedId, setPinnedId] = useState(null);
+ const [xField, setXField] = useState('__date__');
+ const [dataId, setDataId] = useState(null);
const [groupBy, setGroupBy] = useState(() => {
const valid = groupField === '__none__' || groupField === '__name__' || groupField === '__id__'
|| subjectTemplate.some((f) => f.active && f.fieldId === groupField);
@@ -48,42 +44,38 @@ export default function ExportDataModal({
return () => { alive = false; };
}, [experimentId]);
- 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]);
+ const params = useMemo(() => listExportableParams(dailyTemplate, statuses), [dailyTemplate, statuses]);
+ const effDataId = dataId ?? params[0]?.id ?? null;
+ const dataParam = params.find((p) => p.id === effDataId) ?? params[0] ?? null;
- // Members of the pinned dimension populate its value dropdown.
- const pinnedMembers = useMemo(
- () => 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 activeDaily = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
+ const xOptions = useMemo(() => [
+ { id: '__date__', label: 'Date' },
+ { id: '__days__', label: '# Days Reach' },
+ ...activeDaily.map((f) => ({ id: f.fieldId, label: f.label })),
+ ], [activeDaily]);
const matrix = useMemo(
- () => (pinnedMember
- ? buildMatrix({ rowDim, colDim, pinnedMember, groupField: effGroup }, ctx)
- : { columns: [], rows: [], context: { label: '', value: '' }, corner: '', groupAxis: null }),
- [rowDim, colDim, pinnedMember, effGroup, ctx],
+ () => (dataParam
+ ? buildSubjectSeriesMatrix({ xField, dataParam, groupField: groupBy }, { statuses, animals, dailyTemplate })
+ : { columns: [], rows: [], context: { label: 'Data', value: '' }, corner: '', groupAxis: null }),
+ [xField, dataParam, groupBy, statuses, animals, dailyTemplate],
);
const hasData = matrix.rows.length > 0;
- // When the user changes an axis, keep the two distinct and reset the pinned pick.
- function changeRowDim(next) {
- setRowDim(next);
- if (colDim === next) setColDim(otherDimension(next, null) ?? EXPORT_DIMENSIONS.find((d) => d.dim !== next).dim);
- setPinnedId(null);
- }
- function changeColDim(next) {
- setColDim(next);
- if (rowDim === next) setRowDim(EXPORT_DIMENSIONS.find((d) => d.dim !== next).dim);
- setPinnedId(null);
- }
+ const groupedParams = useMemo(() => {
+ const out = [];
+ for (const p of params) {
+ let g = out.find((x) => x.group === p.group);
+ if (!g) { g = { group: p.group, items: [] }; out.push(g); }
+ g.items.push(p);
+ }
+ return out;
+ }, [params]);
function handleExport() {
- if (!hasData || !pinnedMember) return;
- triggerDownload(csvFilename(experimentTitle, pinnedMember.label), toCSV(matrix));
+ if (!hasData || !dataParam) return;
+ triggerDownload(csvFilename(experimentTitle, dataParam.label), toCSV(matrix));
onClose();
}
@@ -96,66 +88,46 @@ export default function ExportDataModal({
);
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 (
- Choose what goes on the rows and columns. The remaining dimension is fixed to one value, shown at the top of the file.
+ One value per subject over an X axis — each column is a subject, each row an X value. Blank where a subject has no value.
-
-
- Rows
- changeRowDim(e.target.value)} className={selectCls}>
- {EXPORT_DIMENSIONS.map((d) => {d.label} )}
-
-
-
- Columns
- changeColDim(e.target.value)} className={selectCls}>
- {colOptions.map((d) => {d.label} )}
-
-
-
-
-
- Fixed: {dimLabel(pinnedDim)}
-
- setPinnedId(e.target.value)}
- className={selectCls}
- >
- {pinnedDim === 'parameter'
- ? groupParamMembers(pinnedMembers).map((g) => (
-
- {g.items.map((m) => {m.label} )}
-
- ))
- : pinnedMembers.map((m) => {m.label} )}
+ X axis (rows)
+ setXField(e.target.value)} className={selectCls}>
+ {xOptions.map((o) => {o.label} )}
- {subjectIsAxis && (
-
- Group by
- setGroupBy(e.target.value)} className={selectCls}>
- — None —
- Name
- Subject ID
- {subjectTemplate.filter((f) => f.active).map((f) => (
- {f.label}
- ))}
-
-
- )}
+
+ Data (cells)
+ setDataId(e.target.value)} className={selectCls}>
+ {groupedParams.map((g) => (
+
+ {g.items.map((p) => {p.label} )}
+
+ ))}
+
+
+
+
+ Group by
+ setGroupBy(e.target.value)} className={selectCls}>
+ — None —
+ Name
+ Subject ID
+ {subjectTemplate.filter((f) => f.active).map((f) => (
+ {f.label}
+ ))}
+
+
{hasData
- ? `${matrix.rows.length} ${matrix.corner.toLowerCase()}${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} ${dimLabel(colDim).toLowerCase()}${matrix.columns.length !== 1 ? 's' : ''}`
+ ? `${matrix.rows.length} row${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${matrix.columns.length !== 1 ? 's' : ''}`
: 'No data for this selection yet.'}
@@ -166,14 +138,3 @@ export default function ExportDataModal({
);
}
-
-// Group parameter members by their .group for 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;
-}
diff --git a/frontend/tests/ExportDataModal.test.jsx b/frontend/tests/ExportDataModal.test.jsx
index 7c866c9..2cdd755 100644
--- a/frontend/tests/ExportDataModal.test.jsx
+++ b/frontend/tests/ExportDataModal.test.jsx
@@ -12,8 +12,9 @@ const animals = [
{ 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 } },
+ { animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5, success_rate: 0.5 } },
+ { animal_id: 'a1', date: '2026-07-02T00:00:00Z', analysis_summary: { total: 7, success_rate: 0.7 } },
+ { animal_id: 'a2', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 9, success_rate: 0.9 } },
];
const subjectTemplate = [{ fieldId: 'grp', label: 'Group', active: true }];
@@ -22,12 +23,9 @@ beforeEach(() => {
client.experimentsApi.getDailyStatuses.mockResolvedValue(statuses);
});
-// Intercepts the Blob passed to URL.createObjectURL during handleExport so tests
-// can inspect the actual CSV text produced, rather than inferring it from the
-// 's DOM value (which can be misleading — see the coercion test below).
-// Also stubs anchor .click() — jsdom treats a real click on an
-// as a navigation attempt, which it doesn't implement; that throws asynchronously
-// (after the test has moved on) and can flakily fail an unrelated later test.
+// Intercept the Blob handed to URL.createObjectURL so tests can read the real CSV.
+// Also stub anchor.click() — jsdom treats a click on as an
+// unimplemented navigation that throws async and can flake a later test.
function mockDownload() {
let blob = null;
const origCreate = global.URL.createObjectURL;
@@ -38,12 +36,8 @@ function mockDownload() {
window.HTMLAnchorElement.prototype.click = jest.fn();
return {
getBlob: () => blob,
- // handleExport schedules `setTimeout(() => URL.revokeObjectURL(url), 0)`; flush
- // that pending macrotask before unmocking, or it fires after restore() has put
- // back the (non-existent, in jsdom) original revokeObjectURL and throws async —
- // an error jest attributes to whatever test happens to run next.
restore: async () => {
- await new Promise((r) => setTimeout(r, 0));
+ await new Promise((r) => setTimeout(r, 0)); // flush handleExport's setTimeout revoke
global.URL.createObjectURL = origCreate;
global.URL.revokeObjectURL = origRevoke;
window.HTMLAnchorElement.prototype.click = origClick;
@@ -76,61 +70,44 @@ function renderModal(props = {}) {
}
describe('ExportDataModal', () => {
- it('defaults to Rows=Date, Columns=Subject and shows the preview', async () => {
+ it('renders X / Data / Group by pickers; X defaults to Date', 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'));
- });
-
- it('defaults Group by to None when groupField is __none__, and the exported CSV has no Group row (Issue-1 regression guard: default export reproduces today\'s export)', async () => {
- const dl = mockDownload();
- renderModal({ groupField: '__none__' });
- await screen.findByLabelText('Rows');
+ expect(await screen.findByLabelText('X axis (rows)')).toHaveValue('__date__');
+ expect(screen.getByLabelText('Data (cells)')).toBeInTheDocument();
expect(screen.getByLabelText('Group by')).toHaveValue('__none__');
- await waitFor(() => expect(screen.getByText(/1 date/i)).toBeInTheDocument());
+ await waitFor(() => expect(screen.getByText(/2 rows × 2 subjects/i)).toBeInTheDocument());
+ });
+
+ it('default export (X=Date, Data=Total attempts) has subject columns, date rows, blank where missing', async () => {
+ const dl = mockDownload();
+ renderModal();
+ await screen.findByLabelText('X axis (rows)');
+ await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
fireEvent.click(screen.getByText('Export CSV'));
const text = await readBlobText(dl.getBlob());
- expect(text).not.toMatch(/^Group,/m);
+ expect(text).toBe('Data,Total attempts\n\nDate,Alpha,Beta\n2026-07-01,5,9\n2026-07-02,7,');
await dl.restore();
});
- it('defaults Group by to the saved field when it is a valid active subject field', async () => {
- renderModal({ groupField: 'grp' });
- await screen.findByLabelText('Rows');
- expect(screen.getByLabelText('Group by')).toHaveValue('grp');
- });
-
- it('coerces Group by to None when the saved field no longer exists in subjectTemplate, so the export carries no stray Group row (Issue-2 guard)', async () => {
- // Note: asserting the 's DOM .value here would be misleading — React's
- // controlled falls back to selecting the first ("__none__")
- // whenever the given value matches no option, regardless of whether the
- // component actually validated/coerced its internal state. So we assert the
- // real behavioral effect (the exported CSV) instead.
+ it('switching X to # Days Reach makes rows the day ordinals', async () => {
const dl = mockDownload();
- renderModal({ groupField: 'ghost-field' });
- await screen.findByLabelText('Rows');
- await waitFor(() => expect(screen.getByText(/1 date/i)).toBeInTheDocument());
+ renderModal();
+ fireEvent.change(await screen.findByLabelText('X axis (rows)'), { target: { value: '__days__' } });
+ await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
fireEvent.click(screen.getByText('Export CSV'));
const text = await readBlobText(dl.getBlob());
- expect(text).not.toMatch(/^Group,/m);
+ expect(text).toBe('Data,Total attempts\n\n# Days Reach,Alpha,Beta\n1,5,9\n2,7,');
+ await dl.restore();
+ });
+
+ it('a valid group field adds a Group row and clusters subjects', async () => {
+ const dl = mockDownload();
+ renderModal({ groupField: 'grp' });
+ await screen.findByLabelText('X axis (rows)');
+ await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
+ fireEvent.click(screen.getByText('Export CSV'));
+ const text = await readBlobText(dl.getBlob());
+ expect(text).toMatch(/^Group,Control,Drug$/m);
await dl.restore();
});
});