From 8ab270a9c95b346f76f96cf2b7effbebebfde621 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Sun, 19 Jul 2026 13:33:12 -0400 Subject: [PATCH] feat(export): modal gains assignable axes + group-by control --- frontend/src/components/ExportDataModal.jsx | 141 ++++++++++++++------ frontend/tests/ExportDataModal.test.jsx | 64 +++++++++ 2 files changed, 166 insertions(+), 39 deletions(-) create mode 100644 frontend/tests/ExportDataModal.test.jsx diff --git a/frontend/src/components/ExportDataModal.jsx b/frontend/src/components/ExportDataModal.jsx index 72f487b..d676521 100644 --- a/frontend/src/components/ExportDataModal.jsx +++ b/frontend/src/components/ExportDataModal.jsx @@ -2,9 +2,12 @@ import React, { useEffect, useMemo, useState } from 'react'; import { experimentsApi } from '../api/client'; import Button from './ui/Button'; 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) { const blob = new Blob([text], { type: 'text/csv;charset=utf-8;' }); @@ -18,11 +21,17 @@ function triggerDownload(filename, text) { 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 [error, setError] = useState(null); 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(() => { let alive = true; @@ -35,33 +44,42 @@ export default function ExportDataModal({ experimentTitle, experimentId, dailyTe return () => { alive = false; }; }, [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. - const effectiveId = selectedId ?? params[0]?.id ?? null; - const selectedParam = params.find((p) => p.id === effectiveId) ?? 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 matrix = useMemo( - () => (selectedParam ? buildMatrix(statuses, animals, selectedParam) : { columns: [], rows: [] }), - [statuses, animals, selectedParam], + () => (pinnedMember + ? 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; - // Group params for rendering, preserving order. - const grouped = 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]); + // 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); + } function handleExport() { - if (!hasData || !selectedParam) return; - triggerDownload(csvFilename(experimentTitle, selectedParam.label), toCSV(matrix)); + if (!hasData || !pinnedMember) return; + triggerDownload(csvFilename(experimentTitle, pinnedMember.label), toCSV(matrix)); onClose(); } @@ -69,38 +87,72 @@ export default function ExportDataModal({ experimentTitle, experimentId, dailyTe if (error) return (
-
- -
+
); + 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 (

- 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.

+
+
+ + +
+
+ + +
+
+
- +
+ {subjectIsAxis && ( +
+ + +
+ )} +

{hasData - ? `${matrix.rows.length} date${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${matrix.columns.length !== 1 ? 's' : ''}` - : 'No data for this parameter yet.'} + ? `${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 selection yet.'}

@@ -110,3 +162,14 @@ export default function ExportDataModal({ experimentTitle, experimentId, dailyTe
); } + +// 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 new file mode 100644 index 0000000..f8924a9 --- /dev/null +++ b/frontend/tests/ExportDataModal.test.jsx @@ -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( + , + ); +} + +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')); + }); +});