9b3d52d89b
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 <noreply@anthropic.com>
428 lines
19 KiB
React
428 lines
19 KiB
React
import React, { useEffect, useState, useMemo } from 'react';
|
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
|
import { experimentsApi, animalsApi } from '../api/client';
|
|
import { ExperimentAnalysisCharts } from '../components/AnalysisCharts';
|
|
import Button from '../components/ui/Button';
|
|
import Modal from '../components/ui/Modal';
|
|
import AnimalForm from '../components/AnimalForm';
|
|
import TemplateEditor from '../components/TemplateEditor';
|
|
import Alert from '../components/ui/Alert';
|
|
import AuditLogSection from '../components/AuditLogSection';
|
|
import ExperimentCalendar from '../components/ExperimentCalendar';
|
|
import ExportDataModal from '../components/ExportDataModal';
|
|
|
|
function AnimalCardList({ animals, subjectTemplate, onNavigate, onEdit }) {
|
|
return (
|
|
<div className="grid gap-3">
|
|
{animals.map((animal) => {
|
|
const infoFields = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId] != null);
|
|
return (
|
|
<div
|
|
key={animal.id}
|
|
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
|
|
onClick={() => onNavigate(animal)}
|
|
>
|
|
<div>
|
|
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
|
|
{animal.animal_name}
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
|
|
</div>
|
|
{infoFields.length > 0 && (
|
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
|
|
{infoFields.map((f) => (
|
|
<span key={f.fieldId} className="text-xs text-gray-400">
|
|
<span className="font-medium text-gray-500">{f.label}:</span> {animal.subject_info[f.fieldId]}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
|
<Button size="sm" variant="secondary" onClick={() => onEdit(animal)}>Edit</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ExperimentDetail() {
|
|
const { id } = useParams();
|
|
const navigate = useNavigate();
|
|
|
|
const [experiment, setExperiment] = useState(null);
|
|
const [animals, setAnimals] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [successMsg, setSuccessMsg] = useState(null);
|
|
|
|
const [showAddAnimal, setShowAddAnimal] = useState(false);
|
|
const [editAnimal, setEditAnimal] = useState(null);
|
|
const [showTemplate, setShowTemplate] = useState(false);
|
|
const [showSubjectTemplate, setShowSubjectTemplate] = useState(false);
|
|
const [showExport, setShowExport] = useState(false);
|
|
const [subjectTemplate, setSubjectTemplate] = useState([]);
|
|
const [dailyTemplate, setDailyTemplate] = useState([]);
|
|
const [experimentStatuses, setExperimentStatuses] = useState([]);
|
|
|
|
const [displayMode, setDisplayMode] = useState(() => localStorage.getItem(`exp-display-mode-${id}`) ?? 'list');
|
|
const [sortField, setSortField] = useState(() => localStorage.getItem(`exp-subject-sort-${id}`) ?? '__name__');
|
|
const [sortDir, setSortDir] = useState(() => localStorage.getItem(`exp-subject-sort-dir-${id}`) ?? 'asc');
|
|
const [groupField, setGroupField] = useState(() => localStorage.getItem(`exp-subject-group-${id}`) ?? '__name__');
|
|
|
|
function updateDisplayMode(mode) {
|
|
setDisplayMode(mode);
|
|
localStorage.setItem(`exp-display-mode-${id}`, mode);
|
|
}
|
|
function updateSort(field, dir) {
|
|
setSortField(field);
|
|
setSortDir(dir);
|
|
localStorage.setItem(`exp-subject-sort-${id}`, field);
|
|
localStorage.setItem(`exp-subject-sort-dir-${id}`, dir);
|
|
}
|
|
function updateGroupField(field) {
|
|
setGroupField(field);
|
|
localStorage.setItem(`exp-subject-group-${id}`, field);
|
|
}
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const [exp, anims, subjTmpl, dailyTmpl, expStatuses] = await Promise.all([
|
|
experimentsApi.get(id),
|
|
animalsApi.list(id),
|
|
experimentsApi.getSubjectTemplate(id),
|
|
experimentsApi.getTemplate(id),
|
|
// Only fetch statuses that have saved analysis_summary — charts are the only consumer,
|
|
// and they filter for analysis_summary.total != null anyway.
|
|
experimentsApi.getDailyStatuses(id, { analysisOnly: true }),
|
|
]);
|
|
setExperiment(exp);
|
|
setAnimals(anims);
|
|
setSubjectTemplate(subjTmpl);
|
|
setDailyTemplate(dailyTmpl);
|
|
setExperimentStatuses(expStatuses);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => { load(); }, [id]);
|
|
|
|
function flash(msg) {
|
|
setSuccessMsg(msg);
|
|
setTimeout(() => setSuccessMsg(null), 3000);
|
|
}
|
|
|
|
function handleAnimalSaved(animal) {
|
|
setShowAddAnimal(false);
|
|
setEditAnimal(null);
|
|
load();
|
|
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`);
|
|
}
|
|
|
|
function handleTemplateSaved(updatedTemplate) {
|
|
setShowTemplate(false);
|
|
if (updatedTemplate) {
|
|
// Update local experiment state so everything downstream re-renders
|
|
setExperiment((prev) => ({ ...prev, template: updatedTemplate }));
|
|
flash('Record template updated.');
|
|
}
|
|
}
|
|
|
|
const sortedAnimals = useMemo(() => {
|
|
const sorted = [...animals].sort((a, b) => {
|
|
let av, bv;
|
|
if (sortField === '__name__') {
|
|
av = a.animal_name ?? '';
|
|
bv = b.animal_name ?? '';
|
|
} else if (sortField === '__id__') {
|
|
av = a.animal_id_string ?? '';
|
|
bv = b.animal_id_string ?? '';
|
|
} else {
|
|
av = a.subject_info?.[sortField] ?? '';
|
|
bv = b.subject_info?.[sortField] ?? '';
|
|
}
|
|
// numeric-aware comparison
|
|
const an = parseFloat(av), bn = parseFloat(bv);
|
|
const cmp = (!isNaN(an) && !isNaN(bn)) ? an - bn : String(av).localeCompare(String(bv));
|
|
return sortDir === 'asc' ? cmp : -cmp;
|
|
});
|
|
return sorted;
|
|
}, [animals, sortField, sortDir, subjectTemplate]);
|
|
|
|
// Group mode: bucket sortedAnimals by the groupField value
|
|
const groupedAnimals = useMemo(() => {
|
|
if (displayMode !== 'group') return null;
|
|
const getVal = (animal) => {
|
|
if (groupField === '__name__') return animal.animal_name ?? '—';
|
|
if (groupField === '__id__') return animal.animal_id_string ?? '—';
|
|
const v = animal.subject_info?.[groupField];
|
|
return v != null && v !== '' ? String(v) : '—';
|
|
};
|
|
const buckets = new Map();
|
|
for (const animal of sortedAnimals) {
|
|
const key = getVal(animal);
|
|
if (!buckets.has(key)) buckets.set(key, []);
|
|
buckets.get(key).push(animal);
|
|
}
|
|
// Sort bucket keys numerically-aware
|
|
return [...buckets.entries()].sort(([a], [b]) => {
|
|
const an = parseFloat(a), bn = parseFloat(b);
|
|
return (!isNaN(an) && !isNaN(bn)) ? an - bn : a.localeCompare(b);
|
|
});
|
|
}, [sortedAnimals, displayMode, groupField]);
|
|
|
|
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading…</div>;
|
|
if (error) return (
|
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
|
<Alert type="error" message={error} />
|
|
<Button variant="secondary" className="mt-4" onClick={() => navigate('/')}>← Back</Button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
|
{/* Breadcrumb */}
|
|
<nav className="text-sm text-gray-500 mb-4">
|
|
<Link to="/" className="hover:text-blue-600">Experiments</Link>
|
|
<span className="mx-2">/</span>
|
|
<span className="text-gray-900 font-medium">{experiment.title}</span>
|
|
</nav>
|
|
|
|
<div className="flex items-start justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">{experiment.title}</h1>
|
|
<p className="text-sm text-gray-500 mt-0.5">
|
|
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="secondary" onClick={() => setShowExport(true)}>Export data</Button>
|
|
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{successMsg && <Alert type="success" message={successMsg} />}
|
|
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
|
|
|
{/* Template strips */}
|
|
<div className="mb-5 space-y-2">
|
|
{/* Daily record template */}
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Daily record</span>
|
|
{(experiment.template ?? []).map((f) => (
|
|
<span
|
|
key={f.key}
|
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
|
|
f.active ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-gray-100 border-gray-200 text-gray-400'
|
|
}`}
|
|
>
|
|
{f.active ? null : <span title="hidden">◌</span>}
|
|
{f.label}
|
|
</span>
|
|
))}
|
|
<button
|
|
onClick={() => setShowTemplate(true)}
|
|
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-blue-600 transition-colors"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
|
|
</svg>
|
|
{(experiment.template ?? []).length === 0 ? 'Set up' : 'Edit'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Subject info template */}
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Subject info</span>
|
|
{subjectTemplate.map((f) => (
|
|
<span
|
|
key={f.key}
|
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
|
|
f.active ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-gray-100 border-gray-200 text-gray-400'
|
|
}`}
|
|
>
|
|
{f.active ? null : <span title="hidden">◌</span>}
|
|
{f.label}
|
|
</span>
|
|
))}
|
|
<button
|
|
onClick={() => setShowSubjectTemplate(true)}
|
|
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-emerald-600 transition-colors"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
|
|
</svg>
|
|
{subjectTemplate.length === 0 ? 'Set up' : 'Edit'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{animals.length === 0 ? (
|
|
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
|
<p className="text-gray-400 mb-3">No animals enrolled in this experiment yet.</p>
|
|
<Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Display controls */}
|
|
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
|
{/* Mode toggle */}
|
|
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
|
{[['list', 'List'], ['group', 'Group']].map(([mode, label]) => (
|
|
<button key={mode} type="button" onClick={() => updateDisplayMode(mode)}
|
|
className={`px-3 py-1 border-l border-gray-200 first:border-l-0 transition-colors
|
|
${displayMode === mode ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{displayMode === 'list' && (
|
|
<>
|
|
<span className="text-xs text-gray-400">Sort by</span>
|
|
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
|
|
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
|
<option value="__name__">Name</option>
|
|
<option value="__id__">Subject ID</option>
|
|
{subjectTemplate.filter((f) => f.active).map((f) => (
|
|
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
|
))}
|
|
</select>
|
|
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
|
|
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
|
|
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{displayMode === 'group' && (
|
|
<>
|
|
<span className="text-xs text-gray-400">Group by</span>
|
|
<select value={groupField} onChange={(e) => updateGroupField(e.target.value)}
|
|
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
|
<option value="__name__">Name</option>
|
|
<option value="__id__">Subject ID</option>
|
|
{subjectTemplate.filter((f) => f.active).map((f) => (
|
|
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
|
))}
|
|
</select>
|
|
<span className="text-xs text-gray-400 ml-1">· sorted within by</span>
|
|
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
|
|
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
|
<option value="__name__">Name</option>
|
|
<option value="__id__">Subject ID</option>
|
|
{subjectTemplate.filter((f) => f.active).map((f) => (
|
|
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
|
))}
|
|
</select>
|
|
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
|
|
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
|
|
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{displayMode === 'list' && (
|
|
<AnimalCardList
|
|
animals={sortedAnimals}
|
|
subjectTemplate={subjectTemplate}
|
|
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
|
onEdit={setEditAnimal}
|
|
/>
|
|
)}
|
|
|
|
{displayMode === 'group' && groupedAnimals && groupedAnimals.map(([groupValue, groupAnimals]) => {
|
|
const groupLabel = (() => {
|
|
if (groupField === '__name__' || groupField === '__id__') return groupValue;
|
|
return subjectTemplate.find((f) => f.fieldId === groupField)?.label
|
|
? `${subjectTemplate.find((f) => f.fieldId === groupField).label}: ${groupValue}`
|
|
: groupValue;
|
|
})();
|
|
return (
|
|
<div key={groupValue} className="mb-5">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{groupLabel}</span>
|
|
<span className="text-xs text-gray-400">({groupAnimals.length})</span>
|
|
<div className="flex-1 border-t border-gray-100" />
|
|
</div>
|
|
<AnimalCardList
|
|
animals={groupAnimals}
|
|
subjectTemplate={subjectTemplate}
|
|
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
|
onEdit={setEditAnimal}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
</>
|
|
)}
|
|
|
|
<ExperimentAnalysisCharts
|
|
animals={animals}
|
|
experimentStatuses={experimentStatuses}
|
|
subjectTemplate={subjectTemplate}
|
|
dailyTemplate={dailyTemplate}
|
|
plotConfig={experiment.plot_config}
|
|
onPersistConfig={(cfg) => experimentsApi.savePlotConfig(id, cfg)}
|
|
/>
|
|
|
|
{animals.length > 0 && (
|
|
<ExperimentCalendar
|
|
experimentId={id}
|
|
template={dailyTemplate}
|
|
/>
|
|
)}
|
|
|
|
<AuditLogSection tableName="animals" limit={5} />
|
|
|
|
{/* Template editor modal */}
|
|
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="full">
|
|
<TemplateEditor experimentId={id} onClose={handleTemplateSaved} />
|
|
</Modal>
|
|
|
|
<Modal isOpen={showSubjectTemplate} onClose={() => setShowSubjectTemplate(false)} title="Subject Info Template" size="full">
|
|
<TemplateEditor
|
|
experimentId={id}
|
|
loadFn={() => experimentsApi.getSubjectTemplate(id)}
|
|
saveFn={(fields) => experimentsApi.updateSubjectTemplate(id, fields)}
|
|
onClose={(updated) => {
|
|
setShowSubjectTemplate(false);
|
|
if (updated) { setSubjectTemplate(updated); flash('Subject info template updated.'); }
|
|
}}
|
|
/>
|
|
</Modal>
|
|
|
|
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
|
|
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
|
|
</Modal>
|
|
|
|
<Modal isOpen={!!editAnimal} onClose={() => setEditAnimal(null)} title="Edit Animal">
|
|
<AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
|
|
</Modal>
|
|
|
|
<Modal isOpen={showExport} onClose={() => setShowExport(false)} title="Export data" size="md">
|
|
<ExportDataModal
|
|
experimentId={id}
|
|
experimentTitle={experiment.title}
|
|
dailyTemplate={dailyTemplate}
|
|
animals={animals}
|
|
subjectTemplate={subjectTemplate}
|
|
groupField={localStorage.getItem(`exp-subject-group-${id}`) ?? '__none__'}
|
|
onClose={() => setShowExport(false)}
|
|
/>
|
|
</Modal>
|
|
|
|
</div>
|
|
);
|
|
}
|