From 6f23f83bc8001a2e6a32c3ba18aea0417cada1ec Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Sun, 19 Jul 2026 15:05:38 -0400 Subject: [PATCH] feat(export): modal becomes X / Data / Group by pickers, subjects as columns --- frontend/src/components/ExportDataModal.jsx | 147 +++++++------------- frontend/tests/ExportDataModal.test.jsx | 93 +++++-------- 2 files changed, 89 insertions(+), 151 deletions(-) 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.

-
-
- - -
-
- - -
-
-
- - setXField(e.target.value)} className={selectCls}> + {xOptions.map((o) => )}
- {subjectIsAxis && ( -
- - -
- )} +
+ + +
+ +
+ + +

{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 here would be misleading — React's - // controlled