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),
|
||||
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),
|
||||
savePlotConfig: (id, config) => api.put(`/experiments/${id}/plot-config`, config).then((r) => r.data),
|
||||
// opts: { date?: string, analysisOnly?: boolean }
|
||||
getDailyStatuses: (id, 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 {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
Legend, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { SubjectSidebar } from './SubjectSidebar';
|
||||
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden } from '../lib/crossSubjectChart';
|
||||
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden, serializePlotConfig, readPlotConfig } from '../lib/crossSubjectChart';
|
||||
|
||||
// ── Color palettes ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,14 +89,14 @@ const HEIGHT_OPTIONS = [
|
||||
{ label: 'L', value: 340 },
|
||||
];
|
||||
|
||||
function useChartSettings(defaultHeight) {
|
||||
function useChartSettings(defaultHeight, initial = {}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [height, setHeight] = useState(defaultHeight);
|
||||
const [yMin, setYMin] = useState('');
|
||||
const [yMax, setYMax] = useState('');
|
||||
const [xZoomMin, setXZoomMin] = useState('');
|
||||
const [xZoomMax, setXZoomMax] = useState('');
|
||||
const [xFieldOverride, setXFieldOverride] = useState(null); // null → use parent's xField
|
||||
const [height, setHeight] = useState(initial.height ?? defaultHeight);
|
||||
const [yMin, setYMin] = useState(initial.yMin ?? '');
|
||||
const [yMax, setYMax] = useState(initial.yMax ?? '');
|
||||
const [xZoomMin, setXZoomMin] = useState(initial.xZoomMin ?? '');
|
||||
const [xZoomMax, setXZoomMax] = useState(initial.xZoomMax ?? '');
|
||||
const [xFieldOverride, setXFieldOverride] = useState(initial.xFieldOverride ?? null); // null → use parent's xField
|
||||
|
||||
const yDomain = useMemo(() => [
|
||||
yMin !== '' ? parseFloat(yMin) : 'auto',
|
||||
@@ -113,6 +113,11 @@ function useChartSettings(defaultHeight) {
|
||||
const isDirty = yMin !== '' || yMax !== '' || xZoomMin !== '' || xZoomMax !== ''
|
||||
|| xFieldOverride !== null || height !== defaultHeight;
|
||||
|
||||
const snapshot = useMemo(
|
||||
() => ({ height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride }),
|
||||
[height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride],
|
||||
);
|
||||
|
||||
function reset() {
|
||||
setHeight(defaultHeight);
|
||||
setYMin(''); setYMax('');
|
||||
@@ -126,7 +131,7 @@ function useChartSettings(defaultHeight) {
|
||||
yMin, setYMin, yMax, setYMax,
|
||||
xZoomMin, setXZoomMin, xZoomMax, setXZoomMax,
|
||||
xFieldOverride, setXFieldOverride,
|
||||
yDomain, xDomain, isDirty, reset,
|
||||
yDomain, xDomain, isDirty, reset, snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -445,27 +450,63 @@ const METRIC_OPTIONS = [
|
||||
{ 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 xAxisFields = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
|
||||
|
||||
const defaultGroup = (groupableFields.find((f) => /^group$/i.test(f.label)) ?? groupableFields[0])?.fieldId ?? '';
|
||||
const [groupBy, setGroupBy] = useState(defaultGroup);
|
||||
const [metric, setMetric] = useState('total');
|
||||
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
|
||||
const [tickStep, setTickStep] = useState(1);
|
||||
// Read the saved config exactly once (component only mounts after the experiment loads).
|
||||
const initialConfigRef = useRef(null);
|
||||
if (initialConfigRef.current === null) initialConfigRef.current = readPlotConfig(plotConfig);
|
||||
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)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set());
|
||||
const [sidebarOpen, setSidebarOpen] = useState(initialConfig.sidebarOpen);
|
||||
const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set(initialConfig.hiddenSubjects));
|
||||
const [highlightedSubject, setHighlightedSubject] = useState(null);
|
||||
|
||||
const toggleHidden = (id) => setHiddenSubjects((prev) => toggleInSet(prev, id));
|
||||
const toggleGroup = (ids, hidden) => setHiddenSubjects((prev) => setIdsHidden(prev, ids, hidden));
|
||||
const showAll = () => setHiddenSubjects(new Set());
|
||||
|
||||
const countsS = useChartSettings(200);
|
||||
const rateS = useChartSettings(160);
|
||||
const countsS = useChartSettings(200, initialConfig.counts);
|
||||
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
|
||||
const { animalRows, lines } = useMemo(() => {
|
||||
@@ -567,15 +608,20 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
|
||||
)}
|
||||
<TickStepControl value={tickStep} onChange={setTickStep} />
|
||||
{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)}
|
||||
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
|
||||
? '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',
|
||||
].join(' ')}>
|
||||
{sidebarOpen ? '◂ Subjects' : '▸ Subjects'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -367,6 +367,8 @@ export default function ExperimentDetail() {
|
||||
experimentStatuses={experimentStatuses}
|
||||
subjectTemplate={subjectTemplate}
|
||||
dailyTemplate={dailyTemplate}
|
||||
plotConfig={experiment.plot_config}
|
||||
onPersistConfig={(cfg) => experimentsApi.savePlotConfig(id, cfg)}
|
||||
/>
|
||||
|
||||
{animals.length > 0 && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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.
|
||||
// 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.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