feat: inline cell editing and prev/next day navigation in day view

Clicking any table cell opens an inline input (text or textarea per
field type). Enter or blur saves; Escape cancels. Unchanged values
skip the API call. Custom fields merge with existing custom_fields;
builtin fields use their key directly.

Prev/next buttons at the bottom navigate to adjacent days that have
data for the selected field, preserving the ?field= param. Day list
is fetched via getCalendar and sorted chronologically.

Tests: 41 total (added 15 new tests for cell editing and day nav).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-04-23 12:59:34 -04:00
parent 6dfb427f91
commit dc46af8d31
2 changed files with 380 additions and 24 deletions
+163 -23
View File
@@ -1,11 +1,11 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
import { format } from 'date-fns';
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, Legend,
} from 'recharts';
import { experimentsApi } from '../api/client';
import { experimentsApi, dailyStatusesApi } from '../api/client';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import DailyStatusForm from '../components/DailyStatusForm';
@@ -23,8 +23,50 @@ function cellText(value) {
return String(value);
}
// ── Inline cell editor ────────────────────────────────────────────────────────
function CellEdit({ value, field, saving, onSave, onCancel }) {
const [val, setVal] = useState(value ?? '');
const ref = useRef(null);
useEffect(() => {
ref.current?.focus();
ref.current?.select();
}, []);
function handleKey(e) {
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
if (e.key === 'Enter' && field.type !== 'textarea') { e.preventDefault(); onSave(val); }
}
const cls = 'w-full text-sm border border-blue-400 rounded px-1.5 py-0.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white disabled:opacity-60';
if (field.type === 'textarea') {
return (
<textarea
ref={ref} value={val} rows={3} disabled={saving}
onChange={(e) => setVal(e.target.value)}
onBlur={() => onSave(val)}
onKeyDown={handleKey}
className={cls}
/>
);
}
return (
<input
ref={ref} type="text" value={val} disabled={saving}
onChange={(e) => setVal(e.target.value)}
onBlur={() => onSave(val)}
onKeyDown={handleKey}
className={cls}
/>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function ExperimentDayView() {
const { id, date } = useParams(); // id = experimentId, date = YYYY-MM-DD
const { id, date } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@@ -34,6 +76,16 @@ export default function ExperimentDayView() {
const [error, setError] = useState(null);
const [editStatus, setEditStatus] = useState(null);
// Inline cell editing
const [editingCell, setEditingCell] = useState(null); // { statusId, fieldId }
const [cellSaving, setCellSaving] = useState(false);
const [cellError, setCellError] = useState(null);
// Day navigation
const [dayList, setDayList] = useState([]);
const selectedField = searchParams.get('field') ?? null;
useEffect(() => {
setLoading(true);
Promise.all([
@@ -48,25 +100,27 @@ export default function ExperimentDayView() {
.finally(() => setLoading(false));
}, [id, date]);
// Fetch ordered list of days with data for this field, for prev/next nav
useEffect(() => {
if (!id || !selectedField) return;
experimentsApi.getCalendar(id, selectedField)
.then(({ days }) => setDayList(Object.keys(days).sort()))
.catch(() => {});
}, [id, selectedField]);
const dailyTemplate = useMemo(() => {
if (!experiment) return [];
const t = experiment.template ?? [];
return t.filter((f) => f.active);
return (experiment.template ?? []).filter((f) => f.active);
}, [experiment]);
const animals = useMemo(() => experiment?.animals ?? [], [experiment]);
// Selected field from URL (passed by calendar). Used to filter which rows show.
const selectedField = searchParams.get('field') ?? null;
// Build map: animal_id → status for this day
const statusByAnimal = useMemo(() => {
const map = {};
statuses.forEach((s) => { map[s.animal_id] = s; });
return map;
}, [statuses]);
// Rows: animals with a valid (non-empty) value for the selected field, or all if no field filter
const rows = useMemo(() => {
return animals
.map((animal) => ({ animal, status: statusByAnimal[animal.id] }))
@@ -79,7 +133,6 @@ export default function ExperimentDayView() {
});
}, [animals, statusByAnimal, selectedField]);
// Bar chart: analysis_summary from each row's status (same contract as calendar modal)
const barData = useMemo(() => {
return rows
.map(({ animal, status }) => {
@@ -95,14 +148,48 @@ export default function ExperimentDayView() {
.filter(Boolean);
}, [rows]);
// Prev / next days with data
const currentDayIdx = dayList.indexOf(date);
const prevDay = currentDayIdx > 0 ? dayList[currentDayIdx - 1] : null;
const nextDay = currentDayIdx < dayList.length - 1 ? dayList[currentDayIdx + 1] : null;
function dayNavUrl(d) {
return `/experiments/${id}/day/${d}${selectedField ? `?field=${selectedField}` : ''}`;
}
// Inline cell save
async function saveCell(status, field, rawValue) {
const value = rawValue?.trim() || null;
// Skip save if unchanged
const current = getFieldValue(status, field);
const currentNorm = current == null ? null : String(current).trim() || null;
if (value === currentNorm) { setEditingCell(null); return; }
setCellSaving(true);
setCellError(null);
try {
let payload;
if (field.builtin) {
payload = { [field.key]: value };
} else {
payload = { custom_fields: { ...status.custom_fields, [field.fieldId]: value } };
}
const updated = await dailyStatusesApi.update(status.id, payload);
setStatuses((prev) => prev.map((s) => s.id === updated.id ? updated : s));
setEditingCell(null);
} catch (err) {
setCellError(err.message ?? 'Save failed');
} finally {
setCellSaving(false);
}
}
const displayDate = date
? format(new Date(date + 'T12:00:00'), 'MMMM d, yyyy')
: date;
if (loading) {
return (
<div className="max-w-5xl mx-auto px-4 py-10 text-center text-gray-400">Loading</div>
);
return <div className="max-w-5xl mx-auto px-4 py-10 text-center text-gray-400">Loading</div>;
}
if (error) {
return (
@@ -132,6 +219,10 @@ export default function ExperimentDayView() {
</Button>
</div>
{cellError && (
<Alert type="error" message={cellError} onDismiss={() => setCellError(null)} className="mb-4" />
)}
{/* Status table */}
{rows.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl text-gray-400">
@@ -185,13 +276,34 @@ export default function ExperimentDayView() {
</span>
)}
</td>
{dailyTemplate.map((f) => (
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px] align-top">
<span className="whitespace-pre-wrap break-words">
{status ? (cellText(getFieldValue(status, f)) ?? <span className="text-gray-300"></span>) : <span className="text-gray-300"></span>}
</span>
</td>
))}
{/* Field cells — click to edit inline */}
{dailyTemplate.map((f) => {
const isEditing = editingCell?.statusId === status?.id && editingCell?.fieldId === f.fieldId;
const text = status ? cellText(getFieldValue(status, f)) : null;
return (
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px] align-top">
{isEditing ? (
<CellEdit
value={text}
field={f}
saving={cellSaving}
onSave={(val) => saveCell(status, f, val)}
onCancel={() => setEditingCell(null)}
/>
) : (
<span
className="whitespace-pre-wrap break-words block rounded px-0.5 -mx-0.5 cursor-pointer hover:bg-blue-50 transition-colors"
title="Click to edit"
onClick={() => status && setEditingCell({ statusId: status.id, fieldId: f.fieldId })}
>
{text ?? <span className="text-gray-300"></span>}
</span>
)}
</td>
);
})}
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
{status && (
<Button size="sm" variant="secondary" onClick={() => setEditStatus(status)}>
@@ -210,7 +322,7 @@ export default function ExperimentDayView() {
{/* Session metrics bar chart */}
{barData.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5">
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 mb-8">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Session metrics</h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
@@ -227,7 +339,35 @@ export default function ExperimentDayView() {
</div>
)}
{/* Edit modal */}
{/* Prev / next day navigation */}
{(prevDay || nextDay) && (
<div className="flex justify-between items-center mt-2 mb-4">
<div>
{prevDay && (
<button
type="button"
onClick={() => navigate(dayNavUrl(prevDay))}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
{format(new Date(prevDay + 'T12:00:00'), 'MMMM d, yyyy')}
</button>
)}
</div>
<div>
{nextDay && (
<button
type="button"
onClick={() => navigate(dayNavUrl(nextDay))}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
{format(new Date(nextDay + 'T12:00:00'), 'MMMM d, yyyy')}
</button>
)}
</div>
</div>
)}
{/* Edit modal (full row) */}
{editStatus && (
<Modal
isOpen={!!editStatus}