feat(frontend): hydrate + debounced-persist cross-subject plot config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ export const experimentsApi = {
|
|||||||
updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data),
|
updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data),
|
||||||
getSubjectTemplate: (id) => api.get(`/experiments/${id}/subject-template`).then((r) => r.data),
|
getSubjectTemplate: (id) => api.get(`/experiments/${id}/subject-template`).then((r) => r.data),
|
||||||
updateSubjectTemplate: (id, template) => api.put(`/experiments/${id}/subject-template`, template).then((r) => r.data),
|
updateSubjectTemplate: (id, template) => api.put(`/experiments/${id}/subject-template`, template).then((r) => r.data),
|
||||||
|
savePlotConfig: (id, config) => api.put(`/experiments/${id}/plot-config`, config).then((r) => r.data),
|
||||||
// opts: { date?: string, analysisOnly?: boolean }
|
// opts: { date?: string, analysisOnly?: boolean }
|
||||||
getDailyStatuses: (id, opts) => {
|
getDailyStatuses: (id, opts) => {
|
||||||
const params = typeof opts === 'string' ? { date: opts } : (opts ?? {});
|
const params = typeof opts === 'string' ? { date: opts } : (opts ?? {});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
Legend, ResponsiveContainer,
|
Legend, ResponsiveContainer,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { SubjectSidebar } from './SubjectSidebar';
|
import { SubjectSidebar } from './SubjectSidebar';
|
||||||
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden } from '../lib/crossSubjectChart';
|
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden, serializePlotConfig, readPlotConfig } from '../lib/crossSubjectChart';
|
||||||
|
|
||||||
// ── Color palettes ─────────────────────────────────────────────────────────────
|
// ── Color palettes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -89,14 +89,14 @@ const HEIGHT_OPTIONS = [
|
|||||||
{ label: 'L', value: 340 },
|
{ label: 'L', value: 340 },
|
||||||
];
|
];
|
||||||
|
|
||||||
function useChartSettings(defaultHeight) {
|
function useChartSettings(defaultHeight, initial = {}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [height, setHeight] = useState(defaultHeight);
|
const [height, setHeight] = useState(initial.height ?? defaultHeight);
|
||||||
const [yMin, setYMin] = useState('');
|
const [yMin, setYMin] = useState(initial.yMin ?? '');
|
||||||
const [yMax, setYMax] = useState('');
|
const [yMax, setYMax] = useState(initial.yMax ?? '');
|
||||||
const [xZoomMin, setXZoomMin] = useState('');
|
const [xZoomMin, setXZoomMin] = useState(initial.xZoomMin ?? '');
|
||||||
const [xZoomMax, setXZoomMax] = useState('');
|
const [xZoomMax, setXZoomMax] = useState(initial.xZoomMax ?? '');
|
||||||
const [xFieldOverride, setXFieldOverride] = useState(null); // null → use parent's xField
|
const [xFieldOverride, setXFieldOverride] = useState(initial.xFieldOverride ?? null); // null → use parent's xField
|
||||||
|
|
||||||
const yDomain = useMemo(() => [
|
const yDomain = useMemo(() => [
|
||||||
yMin !== '' ? parseFloat(yMin) : 'auto',
|
yMin !== '' ? parseFloat(yMin) : 'auto',
|
||||||
@@ -113,6 +113,11 @@ function useChartSettings(defaultHeight) {
|
|||||||
const isDirty = yMin !== '' || yMax !== '' || xZoomMin !== '' || xZoomMax !== ''
|
const isDirty = yMin !== '' || yMax !== '' || xZoomMin !== '' || xZoomMax !== ''
|
||||||
|| xFieldOverride !== null || height !== defaultHeight;
|
|| xFieldOverride !== null || height !== defaultHeight;
|
||||||
|
|
||||||
|
const snapshot = useMemo(
|
||||||
|
() => ({ height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride }),
|
||||||
|
[height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride],
|
||||||
|
);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setHeight(defaultHeight);
|
setHeight(defaultHeight);
|
||||||
setYMin(''); setYMax('');
|
setYMin(''); setYMax('');
|
||||||
@@ -126,7 +131,7 @@ function useChartSettings(defaultHeight) {
|
|||||||
yMin, setYMin, yMax, setYMax,
|
yMin, setYMin, yMax, setYMax,
|
||||||
xZoomMin, setXZoomMin, xZoomMax, setXZoomMax,
|
xZoomMin, setXZoomMin, xZoomMax, setXZoomMax,
|
||||||
xFieldOverride, setXFieldOverride,
|
xFieldOverride, setXFieldOverride,
|
||||||
yDomain, xDomain, isDirty, reset,
|
yDomain, xDomain, isDirty, reset, snapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,27 +450,63 @@ const METRIC_OPTIONS = [
|
|||||||
{ value: 'Failure', label: 'Failure count' },
|
{ value: 'Failure', label: 'Failure count' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate }) {
|
export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate, plotConfig, onPersistConfig }) {
|
||||||
const groupableFields = useMemo(() => (subjectTemplate ?? []).filter((f) => f.active), [subjectTemplate]);
|
const groupableFields = useMemo(() => (subjectTemplate ?? []).filter((f) => f.active), [subjectTemplate]);
|
||||||
const xAxisFields = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
|
const xAxisFields = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
|
||||||
|
|
||||||
const defaultGroup = (groupableFields.find((f) => /^group$/i.test(f.label)) ?? groupableFields[0])?.fieldId ?? '';
|
const defaultGroup = (groupableFields.find((f) => /^group$/i.test(f.label)) ?? groupableFields[0])?.fieldId ?? '';
|
||||||
const [groupBy, setGroupBy] = useState(defaultGroup);
|
// Read the saved config exactly once (component only mounts after the experiment loads).
|
||||||
const [metric, setMetric] = useState('total');
|
const initialConfigRef = useRef(null);
|
||||||
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
|
if (initialConfigRef.current === null) initialConfigRef.current = readPlotConfig(plotConfig);
|
||||||
const [tickStep, setTickStep] = useState(1);
|
const initialConfig = initialConfigRef.current;
|
||||||
|
const [groupBy, setGroupBy] = useState(initialConfig.groupBy ?? defaultGroup);
|
||||||
|
const [metric, setMetric] = useState(initialConfig.metric ?? 'total');
|
||||||
|
const [xField, setXField] = useState(() => initialConfig.xField ?? defaultXField(xAxisFields));
|
||||||
|
const [tickStep, setTickStep] = useState(initialConfig.tickStep ?? 1);
|
||||||
|
|
||||||
// Cross-subject interaction state (client-only; global across both charts)
|
// Cross-subject interaction state (client-only; global across both charts)
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(initialConfig.sidebarOpen);
|
||||||
const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set());
|
const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set(initialConfig.hiddenSubjects));
|
||||||
const [highlightedSubject, setHighlightedSubject] = useState(null);
|
const [highlightedSubject, setHighlightedSubject] = useState(null);
|
||||||
|
|
||||||
const toggleHidden = (id) => setHiddenSubjects((prev) => toggleInSet(prev, id));
|
const toggleHidden = (id) => setHiddenSubjects((prev) => toggleInSet(prev, id));
|
||||||
const toggleGroup = (ids, hidden) => setHiddenSubjects((prev) => setIdsHidden(prev, ids, hidden));
|
const toggleGroup = (ids, hidden) => setHiddenSubjects((prev) => setIdsHidden(prev, ids, hidden));
|
||||||
const showAll = () => setHiddenSubjects(new Set());
|
const showAll = () => setHiddenSubjects(new Set());
|
||||||
|
|
||||||
const countsS = useChartSettings(200);
|
const countsS = useChartSettings(200, initialConfig.counts);
|
||||||
const rateS = useChartSettings(160);
|
const rateS = useChartSettings(160, initialConfig.rate);
|
||||||
|
|
||||||
|
// ── Persist display settings (debounced auto-save) ────────────────────────────
|
||||||
|
const [saveState, setSaveState] = useState('idle'); // 'idle' | 'saving' | 'saved' | 'error'
|
||||||
|
|
||||||
|
const currentConfig = useMemo(
|
||||||
|
() => serializePlotConfig({
|
||||||
|
xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen,
|
||||||
|
counts: countsS.snapshot, rate: rateS.snapshot,
|
||||||
|
}),
|
||||||
|
[xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, countsS.snapshot, rateS.snapshot],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the latest persist callback in a ref so the save effect does not re-run
|
||||||
|
// when the parent passes a new inline function each render.
|
||||||
|
const persistRef = useRef(onPersistConfig);
|
||||||
|
persistRef.current = onPersistConfig;
|
||||||
|
|
||||||
|
const lastSavedRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const serialized = JSON.stringify(currentConfig);
|
||||||
|
if (lastSavedRef.current === null) { lastSavedRef.current = serialized; return; } // skip initial (hydration)
|
||||||
|
// Reverted to the last-saved value before the debounce fired → nothing pending.
|
||||||
|
if (serialized === lastSavedRef.current) { setSaveState('idle'); return; }
|
||||||
|
if (!persistRef.current) { lastSavedRef.current = serialized; setSaveState('idle'); return; }
|
||||||
|
setSaveState('saving');
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
Promise.resolve(persistRef.current(currentConfig))
|
||||||
|
.then(() => { lastSavedRef.current = serialized; setSaveState('saved'); })
|
||||||
|
.catch(() => setSaveState('error'));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [currentConfig]);
|
||||||
|
|
||||||
// Step 1: animalRows — does NOT depend on xField; shared by both charts
|
// Step 1: animalRows — does NOT depend on xField; shared by both charts
|
||||||
const { animalRows, lines } = useMemo(() => {
|
const { animalRows, lines } = useMemo(() => {
|
||||||
@@ -567,15 +608,20 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
|
|||||||
)}
|
)}
|
||||||
<TickStepControl value={tickStep} onChange={setTickStep} />
|
<TickStepControl value={tickStep} onChange={setTickStep} />
|
||||||
{hasData && (
|
{hasData && (
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{saveState === 'saving' && <span className="text-xs text-gray-400">Saving…</span>}
|
||||||
|
{saveState === 'saved' && <span className="text-xs text-emerald-500">Saved ✓</span>}
|
||||||
|
{saveState === 'error' && <span className="text-xs text-amber-500">Couldn't save layout</span>}
|
||||||
<button type="button" onClick={() => setSidebarOpen((o) => !o)}
|
<button type="button" onClick={() => setSidebarOpen((o) => !o)}
|
||||||
className={[
|
className={[
|
||||||
'text-xs px-2 py-0.5 rounded border transition-colors ml-auto',
|
'text-xs px-2 py-0.5 rounded border transition-colors',
|
||||||
sidebarOpen
|
sidebarOpen
|
||||||
? 'bg-gray-100 border-gray-300 text-gray-700'
|
? 'bg-gray-100 border-gray-300 text-gray-700'
|
||||||
: 'bg-white border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300',
|
: 'bg-white border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300',
|
||||||
].join(' ')}>
|
].join(' ')}>
|
||||||
{sidebarOpen ? '◂ Subjects' : '▸ Subjects'}
|
{sidebarOpen ? '◂ Subjects' : '▸ Subjects'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -367,6 +367,8 @@ export default function ExperimentDetail() {
|
|||||||
experimentStatuses={experimentStatuses}
|
experimentStatuses={experimentStatuses}
|
||||||
subjectTemplate={subjectTemplate}
|
subjectTemplate={subjectTemplate}
|
||||||
dailyTemplate={dailyTemplate}
|
dailyTemplate={dailyTemplate}
|
||||||
|
plotConfig={experiment.plot_config}
|
||||||
|
onPersistConfig={(cfg) => experimentsApi.savePlotConfig(id, cfg)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{animals.length > 0 && (
|
{animals.length > 0 && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
|
||||||
// recharts uses ResizeObserver (absent in jsdom); stub all used pieces as passthroughs.
|
// recharts uses ResizeObserver (absent in jsdom); stub all used pieces as passthroughs.
|
||||||
// Coverage boundary: with recharts mocked out, these tests cover only the sidebar
|
// Coverage boundary: with recharts mocked out, these tests cover only the sidebar
|
||||||
@@ -59,3 +59,68 @@ it('unchecking a subject in the sidebar persists across re-render', () => {
|
|||||||
expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
|
expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
|
||||||
expect(screen.getByText('Show all')).toBeInTheDocument();
|
expect(screen.getByText('Show all')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hydrates hidden subjects and sidebar-open from plotConfig', () => {
|
||||||
|
render(
|
||||||
|
<ExperimentAnalysisCharts
|
||||||
|
animals={ANIMALS}
|
||||||
|
experimentStatuses={STATUSES}
|
||||||
|
subjectTemplate={SUBJECT_TEMPLATE}
|
||||||
|
dailyTemplate={DAILY_TEMPLATE}
|
||||||
|
plotConfig={{ hiddenSubjects: ['a1'], sidebarOpen: true }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
|
||||||
|
expect(screen.getByLabelText('Mouse-B1').checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('debounced auto-save', () => {
|
||||||
|
beforeEach(() => jest.useFakeTimers());
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
|
it('does not call onPersistConfig on initial render', () => {
|
||||||
|
const onPersist = jest.fn();
|
||||||
|
render(
|
||||||
|
<ExperimentAnalysisCharts
|
||||||
|
animals={ANIMALS} experimentStatuses={STATUSES}
|
||||||
|
subjectTemplate={SUBJECT_TEMPLATE} dailyTemplate={DAILY_TEMPLATE}
|
||||||
|
plotConfig={null} onPersistConfig={onPersist}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
act(() => { jest.advanceTimersByTime(1500); });
|
||||||
|
expect(onPersist).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onPersistConfig ~1s after a change, with the updated config', () => {
|
||||||
|
const onPersist = jest.fn().mockResolvedValue({});
|
||||||
|
render(
|
||||||
|
<ExperimentAnalysisCharts
|
||||||
|
animals={ANIMALS} experimentStatuses={STATUSES}
|
||||||
|
subjectTemplate={SUBJECT_TEMPLATE} dailyTemplate={DAILY_TEMPLATE}
|
||||||
|
plotConfig={{ sidebarOpen: true }} onPersistConfig={onPersist}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText('Mouse-A1'));
|
||||||
|
expect(onPersist).not.toHaveBeenCalled();
|
||||||
|
act(() => { jest.advanceTimersByTime(1100); });
|
||||||
|
expect(onPersist).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onPersist.mock.calls[0][0].hiddenSubjects).toContain('a1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not save and clears the saving hint when a change is reverted before the debounce fires', () => {
|
||||||
|
const onPersist = jest.fn().mockResolvedValue({});
|
||||||
|
render(
|
||||||
|
<ExperimentAnalysisCharts
|
||||||
|
animals={ANIMALS} experimentStatuses={STATUSES}
|
||||||
|
subjectTemplate={SUBJECT_TEMPLATE} dailyTemplate={DAILY_TEMPLATE}
|
||||||
|
plotConfig={{ sidebarOpen: true }} onPersistConfig={onPersist}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText('Mouse-A1')); // hide a1
|
||||||
|
expect(screen.getByText('Saving…')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByLabelText('Mouse-A1')); // revert before the 1s timer
|
||||||
|
act(() => { jest.advanceTimersByTime(1100); });
|
||||||
|
expect(onPersist).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText('Saving…')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user