9535f86970
New ExperimentCalendar component added as a third view mode (List / Group / Calendar)
on the Experiment Detail page.
Backend:
- GET /api/experiments/:id/calendar?field=<fieldIdOrBuiltinKey>
Returns { field, days: { "YYYY-MM-DD": count } } where count = number of
subjects with a non-null/non-empty value for the chosen field on that date.
Supports both builtin fields (vitals, treatment, notes, experiment_description)
and custom fields addressed by fieldId UUID.
Frontend:
- ExperimentCalendar component: navigable monthly grid, field selector
(defaults to first field with "weight" in label, else first active field),
blue badges showing subject count per day, click to open modal with all
subjects' data for that day.
- Integrated into ExperimentDetail as displayMode "calendar", persisted
in localStorage alongside the existing list/group modes.
- experimentsApi.getCalendar() added to API client.
Tests:
- backend/tests/calendar.test.js: 8 unit tests (404, empty days, builtin
count, custom field count, whitespace ignored, multi-month, field echo,
missing param).
- frontend/tests/ExperimentCalendar.test.jsx: 13 RTL tests covering
rendering, default field selection, count badges, month navigation,
field-change re-fetch, day click modal, and multi-subject display.
All 47 backend + 13 calendar frontend tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
466 lines
19 KiB
React
466 lines
19 KiB
React
import React, { useState, useEffect, useRef } from 'react';
|
|
import { experimentsApi } from '../api/client';
|
|
import Button from './ui/Button';
|
|
import Alert from './ui/Alert';
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
function toKey(label) {
|
|
return label.toLowerCase().trim()
|
|
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 64);
|
|
}
|
|
|
|
// ── Tag chip ───────────────────────────────────────────────────────────────────
|
|
|
|
function TagChip({ value, onRemove }) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium">
|
|
{value}
|
|
<button
|
|
type="button"
|
|
aria-label={`Remove tag ${value}`}
|
|
onClick={onRemove}
|
|
className="text-indigo-400 hover:text-indigo-700 leading-none"
|
|
>✕</button>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ── Inline tag editor (expanded panel below a field row) ──────────────────────
|
|
|
|
function TagPanel({ field, onChange, onAddTag, onRemoveTag }) {
|
|
const [draft, setDraft] = useState('');
|
|
const [err, setErr] = useState('');
|
|
const disabled = !!field.tagsDisabled;
|
|
|
|
function commit() {
|
|
const v = draft.trim();
|
|
if (!v) { setErr('Tag cannot be empty'); return; }
|
|
if (v.length > 512) { setErr('Tag must be ≤512 characters'); return; }
|
|
if ((field.tags ?? []).includes(v)) { setErr('Tag already exists'); return; }
|
|
setErr('');
|
|
onAddTag(field.fieldId, v);
|
|
setDraft('');
|
|
}
|
|
|
|
return (
|
|
<div className="px-4 pb-3 pt-2 bg-indigo-50/40 border-t border-indigo-100 rounded-b-lg">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<p className="text-xs font-semibold text-indigo-600 uppercase tracking-wide">Quick-fill tags</p>
|
|
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={disabled}
|
|
onChange={(e) => onChange({ ...field, tagsDisabled: e.target.checked })}
|
|
className="w-3.5 h-3.5 accent-indigo-600"
|
|
/>
|
|
Disable tagging
|
|
</label>
|
|
</div>
|
|
|
|
{!disabled && (
|
|
<>
|
|
<div className="flex flex-wrap gap-1.5 mb-2 min-h-[24px]">
|
|
{(field.tags ?? []).length === 0 && (
|
|
<span className="text-xs text-gray-400 italic">No tags yet.</span>
|
|
)}
|
|
{(field.tags ?? []).map((t) => (
|
|
<TagChip key={t} value={t} onRemove={() => onRemoveTag(field.fieldId, t)} />
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex gap-2 items-start">
|
|
<div className="flex-1">
|
|
<input
|
|
aria-label={`Add tag for ${field.label}`}
|
|
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 ${err ? 'border-red-400' : 'border-gray-300'}`}
|
|
placeholder="Type a tag value…"
|
|
value={draft}
|
|
onChange={(e) => { setDraft(e.target.value); setErr(''); }}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }}
|
|
/>
|
|
{err && <p className="text-xs text-red-500 mt-0.5">{err}</p>}
|
|
</div>
|
|
<Button size="sm" type="button" onClick={commit}>+ Add</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{disabled && (
|
|
<p className="text-xs text-gray-400 italic">Tagging is disabled for this field. Existing tags are preserved.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Single field row (draggable) ───────────────────────────────────────────────
|
|
|
|
function FieldRow({
|
|
field, index, total,
|
|
onChange, onToggle, onRemove, onAddTag, onRemoveTag,
|
|
allKeys,
|
|
// drag props
|
|
onDragStart, onDragEnter, onDragEnd,
|
|
isDragOver,
|
|
}) {
|
|
const [labelDraft, setLabelDraft] = useState(field.label);
|
|
const [labelError, setLabelError] = useState('');
|
|
const [showTags, setShowTags] = useState(false);
|
|
|
|
useEffect(() => { setLabelDraft(field.label); }, [field.label]);
|
|
|
|
function commitLabel() {
|
|
const trimmed = labelDraft.trim();
|
|
if (!trimmed) { setLabelError('Label cannot be empty'); return; }
|
|
if (trimmed.length > 128) { setLabelError('Label must be ≤128 characters'); return; }
|
|
if (!field.builtin) {
|
|
const newKey = toKey(trimmed);
|
|
if (!newKey) { setLabelError('Label must contain at least one alphanumeric character'); return; }
|
|
if (allKeys.find((k) => k !== field.key && k === newKey)) {
|
|
setLabelError('A field with this name already exists'); return;
|
|
}
|
|
}
|
|
setLabelError('');
|
|
onChange({ ...field, label: trimmed, key: field.builtin ? field.key : toKey(trimmed) });
|
|
}
|
|
|
|
const tagCount = (field.tags ?? []).length;
|
|
|
|
return (
|
|
<div
|
|
className={`rounded-lg border transition-all ${isDragOver ? 'border-blue-400 shadow-md scale-[1.01]' : 'border-gray-200'} ${field.active ? 'bg-white' : 'bg-gray-50 opacity-60'}`}
|
|
draggable
|
|
onDragStart={() => onDragStart(index)}
|
|
onDragEnter={() => onDragEnter(index)}
|
|
onDragEnd={onDragEnd}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
>
|
|
{/* Main row */}
|
|
<div className="flex items-center gap-3 px-3 py-2.5">
|
|
{/* Drag handle */}
|
|
<span
|
|
className="text-gray-300 hover:text-gray-500 cursor-grab active:cursor-grabbing select-none text-lg leading-none"
|
|
title="Drag to reorder"
|
|
aria-label="Drag handle"
|
|
>⠿</span>
|
|
|
|
{/* Label input */}
|
|
<div className="flex-1 min-w-0">
|
|
<input
|
|
aria-label={`Field label for ${field.key}`}
|
|
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 ${labelError ? 'border-red-400' : 'border-gray-300'} ${!field.active ? 'bg-gray-100' : 'bg-white'}`}
|
|
value={labelDraft}
|
|
disabled={!field.active}
|
|
onChange={(e) => { setLabelDraft(e.target.value); setLabelError(''); }}
|
|
onBlur={commitLabel}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
|
|
/>
|
|
{labelError && <p className="text-xs text-red-500 mt-0.5">{labelError}</p>}
|
|
<p className="text-xs text-gray-400 mt-0.5 font-mono">
|
|
key: {field.key}
|
|
{field.fieldId && (
|
|
<span className="ml-2 text-gray-300" title={`Stable field ID: ${field.fieldId}`}>
|
|
· id: {field.fieldId.slice(0, 8)}…
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Type selector */}
|
|
<select
|
|
aria-label={`Field type for ${field.key}`}
|
|
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
|
value={field.type}
|
|
disabled={!field.active}
|
|
onChange={(e) => onChange({ ...field, type: e.target.value })}
|
|
>
|
|
<option value="text">Short text</option>
|
|
<option value="textarea">Long text</option>
|
|
</select>
|
|
|
|
{/* Width (text fields only) */}
|
|
{field.type === 'text' && (
|
|
<select
|
|
aria-label={`Width for ${field.key}`}
|
|
title="Input width in the entry form"
|
|
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
|
value={field.width ?? 100}
|
|
disabled={!field.active}
|
|
onChange={(e) => onChange({ ...field, width: Number(e.target.value) })}
|
|
>
|
|
<option value={100}>Full</option>
|
|
<option value={75}>3/4</option>
|
|
<option value={67}>2/3</option>
|
|
<option value={50}>1/2</option>
|
|
<option value={42}>5/12</option>
|
|
<option value={33}>1/3</option>
|
|
<option value={25}>1/4</option>
|
|
<option value={17}>1/6</option>
|
|
<option value={8}>1/12</option>
|
|
</select>
|
|
)}
|
|
|
|
{/* Abbreviation */}
|
|
<input
|
|
aria-label={`Abbreviation for ${field.key}`}
|
|
className="text-xs border border-gray-200 rounded px-2 py-1 w-20 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
|
placeholder="Abbr"
|
|
title="Column abbreviation (≤10 chars)"
|
|
maxLength={10}
|
|
value={field.abbr ?? ''}
|
|
disabled={!field.active}
|
|
onChange={(e) => onChange({ ...field, abbr: e.target.value })}
|
|
/>
|
|
|
|
{/* Unit */}
|
|
<input
|
|
aria-label={`Unit for ${field.key}`}
|
|
className="text-xs border border-gray-200 rounded px-2 py-1 w-20 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
|
placeholder="Unit"
|
|
title="Measurement unit (≤10 chars)"
|
|
maxLength={10}
|
|
value={field.unit ?? ''}
|
|
disabled={!field.active}
|
|
onChange={(e) => onChange({ ...field, unit: e.target.value })}
|
|
/>
|
|
|
|
{/* Tags toggle */}
|
|
<button
|
|
type="button"
|
|
title={showTags ? 'Hide tags' : 'Manage quick-fill tags'}
|
|
aria-label={`Manage tags for ${field.label}`}
|
|
onClick={() => setShowTags((v) => !v)}
|
|
className={`relative text-sm px-2 py-1 rounded border transition-colors ${
|
|
showTags
|
|
? 'border-indigo-400 bg-indigo-50 text-indigo-700'
|
|
: 'border-gray-200 text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-700'
|
|
}`}
|
|
>
|
|
🏷
|
|
{tagCount > 0 && (
|
|
<span className="absolute -top-1.5 -right-1.5 bg-indigo-600 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center leading-none">
|
|
{tagCount > 9 ? '9+' : tagCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Hide / Show */}
|
|
<button
|
|
type="button"
|
|
title={field.active ? 'Hide field' : 'Show field'}
|
|
aria-label={field.active ? `Hide ${field.label}` : `Show ${field.label}`}
|
|
onClick={() => onToggle(field.fieldId)}
|
|
className={`text-sm px-2 py-1 rounded border transition-colors ${
|
|
field.active
|
|
? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
|
|
: 'border-green-200 text-green-600 hover:bg-green-50'
|
|
}`}
|
|
>
|
|
{field.active ? 'Hide' : 'Show'}
|
|
</button>
|
|
|
|
{/* Remove (custom fields only) */}
|
|
{!field.builtin && (
|
|
<button
|
|
type="button"
|
|
title="Remove field permanently"
|
|
aria-label={`Remove ${field.label}`}
|
|
onClick={() => onRemove(field.fieldId)}
|
|
className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
|
|
>✕</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tag panel (collapsible) */}
|
|
{showTags && (
|
|
<TagPanel field={field} onChange={onChange} onAddTag={onAddTag} onRemoveTag={onRemoveTag} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main TemplateEditor ────────────────────────────────────────────────────────
|
|
|
|
export default function TemplateEditor({ experimentId, onClose, loadFn, saveFn }) {
|
|
const [fields, setFields] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
const [success, setSuccess] = useState(false);
|
|
|
|
// Add-field form
|
|
const [newLabel, setNewLabel] = useState('');
|
|
const [newType, setNewType] = useState('text');
|
|
const [addError, setAddError] = useState('');
|
|
|
|
// Drag state
|
|
const dragIndex = useRef(null);
|
|
const [dragOverIndex, setDragOverIndex] = useState(null);
|
|
|
|
useEffect(() => {
|
|
const fn = loadFn ?? (() => experimentsApi.getTemplate(experimentId));
|
|
fn().then(setFields).catch((e) => setError(e.message)).finally(() => setLoading(false));
|
|
}, [experimentId]);
|
|
|
|
// ── field mutation helpers ─────────────────────────────────────────────────
|
|
|
|
function updateField(updated) {
|
|
setFields((prev) => prev.map((f) => f.fieldId === updated.fieldId ? updated : f));
|
|
}
|
|
function toggleField(fieldId) {
|
|
setFields((prev) => prev.map((f) => f.fieldId === fieldId ? { ...f, active: !f.active } : f));
|
|
}
|
|
function removeField(fieldId) {
|
|
setFields((prev) => prev.filter((f) => f.fieldId !== fieldId));
|
|
}
|
|
|
|
// ── tag helpers ────────────────────────────────────────────────────────────
|
|
|
|
function addTag(fieldId, value) {
|
|
setFields((prev) => prev.map((f) =>
|
|
f.fieldId === fieldId
|
|
? { ...f, tags: [...(f.tags ?? []), value] }
|
|
: f
|
|
));
|
|
}
|
|
function removeTag(fieldId, value) {
|
|
setFields((prev) => prev.map((f) =>
|
|
f.fieldId === fieldId
|
|
? { ...f, tags: (f.tags ?? []).filter((t) => t !== value) }
|
|
: f
|
|
));
|
|
}
|
|
|
|
// ── drag handlers ──────────────────────────────────────────────────────────
|
|
|
|
function handleDragStart(index) {
|
|
dragIndex.current = index;
|
|
}
|
|
function handleDragEnter(index) {
|
|
setDragOverIndex(index);
|
|
}
|
|
function handleDragEnd() {
|
|
const from = dragIndex.current;
|
|
const to = dragOverIndex;
|
|
if (from !== null && to !== null && from !== to) {
|
|
setFields((prev) => {
|
|
const next = [...prev];
|
|
const [moved] = next.splice(from, 1);
|
|
next.splice(to, 0, moved);
|
|
return next;
|
|
});
|
|
}
|
|
dragIndex.current = null;
|
|
setDragOverIndex(null);
|
|
}
|
|
|
|
// ── add new field ──────────────────────────────────────────────────────────
|
|
|
|
function addField() {
|
|
const trimmed = newLabel.trim();
|
|
if (!trimmed) { setAddError('Field name is required'); return; }
|
|
const key = toKey(trimmed);
|
|
if (!key) { setAddError('Name must contain at least one alphanumeric character'); return; }
|
|
if (fields.some((f) => f.key === key)) { setAddError(`A field with key "${key}" already exists`); return; }
|
|
setAddError('');
|
|
const fieldId = crypto.randomUUID();
|
|
setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true, tags: [] }]);
|
|
setNewLabel('');
|
|
setNewType('text');
|
|
}
|
|
|
|
// ── save ───────────────────────────────────────────────────────────────────
|
|
|
|
async function save() {
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const fn = saveFn ?? ((f) => experimentsApi.updateTemplate(experimentId, f));
|
|
await fn(fields);
|
|
setSuccess(true);
|
|
setTimeout(() => { setSuccess(false); onClose(fields); }, 700);
|
|
} catch (e) {
|
|
setError(e.message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
const allKeys = fields.map((f) => f.key);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{loading && <p className="text-sm text-gray-400">Loading template…</p>}
|
|
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
|
{success && <Alert type="success" message="Template saved!" />}
|
|
|
|
{!loading && (
|
|
<>
|
|
{/* Field list */}
|
|
<div className="space-y-2">
|
|
{fields.length === 0 && (
|
|
<p className="text-sm text-gray-400 italic text-center py-4">No fields yet. Add one below.</p>
|
|
)}
|
|
{fields.map((field, index) => (
|
|
<FieldRow
|
|
key={field.fieldId}
|
|
field={field}
|
|
index={index}
|
|
total={fields.length}
|
|
onChange={updateField}
|
|
onToggle={toggleField}
|
|
onRemove={removeField}
|
|
onAddTag={addTag}
|
|
onRemoveTag={removeTag}
|
|
allKeys={allKeys}
|
|
onDragStart={handleDragStart}
|
|
onDragEnter={handleDragEnter}
|
|
onDragEnd={handleDragEnd}
|
|
isDragOver={dragOverIndex === index}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Add new field */}
|
|
<div className="border-t pt-4">
|
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Add New Field</p>
|
|
<div className="flex gap-2 items-start">
|
|
<div className="flex-1">
|
|
<input
|
|
aria-label="New field name"
|
|
className={`w-full text-sm rounded border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 ${addError ? 'border-red-400' : 'border-gray-300'}`}
|
|
placeholder="Field name, e.g. Blood Pressure"
|
|
value={newLabel}
|
|
onChange={(e) => { setNewLabel(e.target.value); setAddError(''); }}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addField(); } }}
|
|
/>
|
|
{addError && <p className="text-xs text-red-500 mt-0.5">{addError}</p>}
|
|
</div>
|
|
<select
|
|
aria-label="New field type"
|
|
className="text-sm border border-gray-300 rounded px-2 py-2 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400"
|
|
value={newType}
|
|
onChange={(e) => setNewType(e.target.value)}
|
|
>
|
|
<option value="text">Short text</option>
|
|
<option value="textarea">Long text</option>
|
|
</select>
|
|
<Button size="sm" type="button" onClick={addField}>+ Add</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-400">
|
|
Drag <span className="font-mono">⠿</span> to reorder · <strong>Hide</strong> removes a builtin field from forms while preserving its data · <strong>🏷</strong> manages quick-fill tags
|
|
</p>
|
|
|
|
{/* Actions */}
|
|
<div className="flex justify-end gap-3 border-t pt-4">
|
|
<Button variant="secondary" onClick={() => onClose(null)} disabled={saving}>Cancel</Button>
|
|
<Button onClick={save} loading={saving}>Save Template</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|