From 9b3d52d89b9665332ab1e22053d9fdbcb7f164ca Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Sun, 19 Jul 2026 13:59:48 -0400 Subject: [PATCH] fix(export): default group-by to None when unset/stale; polish Final code review of the configurable-data-export feature: ExperimentDetail was forwarding its on-page groupField state (defaults to '__name__') into ExportDataModal, leaking a clustering default into the default (untouched) export. Now passes the raw saved localStorage value, defaulting to '__none__'. The modal also now validates the incoming groupField against '__none__'/'__name__'/'__id__'/active subjectTemplate fields and coerces to '__none__' otherwise, guarding against stale/deleted subject_info fields. Plus a stale module comment fix and optional-chaining consistency fix in listSubjectMembers. Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/ExportDataModal.jsx | 6 +- frontend/src/lib/dataExport.js | 6 +- frontend/src/pages/ExperimentDetail.jsx | 2 +- frontend/tests/ExportDataModal.test.jsx | 72 +++++++++++++++++++++ frontend/tests/dataExport.test.js | 19 ++++++ 5 files changed, 101 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/ExportDataModal.jsx b/frontend/src/components/ExportDataModal.jsx index d676521..d8cc000 100644 --- a/frontend/src/components/ExportDataModal.jsx +++ b/frontend/src/components/ExportDataModal.jsx @@ -31,7 +31,11 @@ export default function ExportDataModal({ const [rowDim, setRowDim] = useState('date'); const [colDim, setColDim] = useState('subject'); const [pinnedId, setPinnedId] = useState(null); - const [groupBy, setGroupBy] = useState(groupField ?? '__none__'); + const [groupBy, setGroupBy] = useState(() => { + const valid = groupField === '__none__' || groupField === '__name__' || groupField === '__id__' + || subjectTemplate.some((f) => f.active && f.fieldId === groupField); + return valid ? groupField : '__none__'; + }); useEffect(() => { let alive = true; diff --git a/frontend/src/lib/dataExport.js b/frontend/src/lib/dataExport.js index 173db8a..a44c268 100644 --- a/frontend/src/lib/dataExport.js +++ b/frontend/src/lib/dataExport.js @@ -1,4 +1,6 @@ -// Pure helpers for exporting daily-parameter data as a date×subject CSV matrix. +// Pure helpers for exporting daily-parameter data as a CSV matrix. buildMatrix is +// dimension-agnostic: rows and columns are assignable to Date/Subject/Parameter, +// with the leftover dimension pinned to a single value. // No React, no I/O — mirrors the crossSubjectChart.js pure-helper pattern. // Read a single parameter's raw value from one daily status. @@ -112,7 +114,7 @@ export function listSubjectMembers(animals, groupField) { const members = list.map((a) => { const name = a?.animal_name ?? ''; const label = nameCounts.get(name) > 1 ? `${name} (${a?.animal_id_string ?? ''})` : name; - return { id: a.id, label, animalId: a.id, group: subjectGroupValue(a, groupField) }; + return { id: a?.id, label, animalId: a?.id, group: subjectGroupValue(a, groupField) }; }); const grouped = !!groupField && groupField !== '__none__'; members.sort((x, y) => { diff --git a/frontend/src/pages/ExperimentDetail.jsx b/frontend/src/pages/ExperimentDetail.jsx index 11a1183..76f8503 100644 --- a/frontend/src/pages/ExperimentDetail.jsx +++ b/frontend/src/pages/ExperimentDetail.jsx @@ -417,7 +417,7 @@ export default function ExperimentDetail() { dailyTemplate={dailyTemplate} animals={animals} subjectTemplate={subjectTemplate} - groupField={groupField} + groupField={localStorage.getItem(`exp-subject-group-${id}`) ?? '__none__'} onClose={() => setShowExport(false)} /> diff --git a/frontend/tests/ExportDataModal.test.jsx b/frontend/tests/ExportDataModal.test.jsx index f8924a9..7c866c9 100644 --- a/frontend/tests/ExportDataModal.test.jsx +++ b/frontend/tests/ExportDataModal.test.jsx @@ -22,6 +22,44 @@ 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