feat: add experiment calendar view with per-day subject counts
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>
This commit is contained in:
@@ -1,21 +1,111 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { experimentsApi } from '../api/client';
|
||||
import Button from './ui/Button';
|
||||
import Alert from './ui/Alert';
|
||||
|
||||
// Slugify a label into a valid field key
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function toKey(label) {
|
||||
return label
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 64);
|
||||
return label.toLowerCase().trim()
|
||||
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 64);
|
||||
}
|
||||
|
||||
function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
|
||||
// ── 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]);
|
||||
|
||||
@@ -23,115 +113,248 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
|
||||
const trimmed = labelDraft.trim();
|
||||
if (!trimmed) { setLabelError('Label cannot be empty'); return; }
|
||||
if (trimmed.length > 128) { setLabelError('Label must be ≤128 characters'); return; }
|
||||
|
||||
// For custom fields, also ensure the derived key is unique
|
||||
if (!field.builtin) {
|
||||
const newKey = toKey(trimmed);
|
||||
if (!newKey) { setLabelError('Label must contain at least one alphanumeric character'); return; }
|
||||
const duplicate = allKeys.find((k) => k !== field.key && k === newKey);
|
||||
if (duplicate) { setLabelError('A field with this name already exists'); 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) });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${field.active ? 'bg-white border-gray-200' : 'bg-gray-50 border-gray-100 opacity-60'}`}>
|
||||
{/* Drag handle placeholder (visual only) */}
|
||||
<span className="text-gray-300 cursor-default select-none text-lg leading-none">⠿</span>
|
||||
const tagCount = (field.tags ?? []).length;
|
||||
|
||||
{/* 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}
|
||||
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) => { setLabelDraft(e.target.value); setLabelError(''); }}
|
||||
onBlur={commitLabel}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
|
||||
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 })}
|
||||
/>
|
||||
{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)}…
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{/* Type badge */}
|
||||
<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>
|
||||
|
||||
{/* Toggle active */}
|
||||
<button
|
||||
title={field.active ? 'Hide field' : 'Show field'}
|
||||
aria-label={field.active ? `Hide field ${field.label}` : `Show field ${field.label}`}
|
||||
onClick={() => onToggle(field.key)}
|
||||
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 — only for custom fields */}
|
||||
{!field.builtin && (
|
||||
<button
|
||||
title="Remove field permanently"
|
||||
aria-label={`Remove field ${field.label}`}
|
||||
onClick={() => onRemove(field.key)}
|
||||
className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/* Tag panel (collapsible) */}
|
||||
{showTags && (
|
||||
<TagPanel field={field} onChange={onChange} onAddTag={onAddTag} onRemoveTag={onRemoveTag} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TemplateEditor({ experimentId, onClose }) {
|
||||
// ── 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(() => {
|
||||
experimentsApi.getTemplate(experimentId)
|
||||
.then(setFields)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
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.key === updated.key ? updated : f));
|
||||
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));
|
||||
}
|
||||
|
||||
function toggleField(key) {
|
||||
setFields((prev) => prev.map((f) => f.key === key ? { ...f, active: !f.active } : f));
|
||||
// ── 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
|
||||
));
|
||||
}
|
||||
|
||||
function removeField(key) {
|
||||
setFields((prev) => prev.filter((f) => f.key !== key));
|
||||
// ── 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();
|
||||
@@ -141,18 +364,21 @@ export default function TemplateEditor({ experimentId, onClose }) {
|
||||
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 }]);
|
||||
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 {
|
||||
await experimentsApi.updateTemplate(experimentId, fields);
|
||||
const fn = saveFn ?? ((f) => experimentsApi.updateTemplate(experimentId, f));
|
||||
await fn(fields);
|
||||
setSuccess(true);
|
||||
setTimeout(() => { setSuccess(false); onClose(fields); }, 800);
|
||||
setTimeout(() => { setSuccess(false); onClose(fields); }, 700);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
@@ -165,7 +391,7 @@ export default function TemplateEditor({ experimentId, onClose }) {
|
||||
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)} />}
|
||||
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||
{success && <Alert type="success" message="Template saved!" />}
|
||||
|
||||
{!loading && (
|
||||
@@ -175,14 +401,22 @@ export default function TemplateEditor({ experimentId, onClose }) {
|
||||
{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) => (
|
||||
{fields.map((field, index) => (
|
||||
<FieldRow
|
||||
key={field.key}
|
||||
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>
|
||||
@@ -211,14 +445,12 @@ export default function TemplateEditor({ experimentId, onClose }) {
|
||||
<option value="text">Short text</option>
|
||||
<option value="textarea">Long text</option>
|
||||
</select>
|
||||
<Button size="sm" onClick={addField} type="button">+ Add</Button>
|
||||
<Button size="sm" type="button" onClick={addField}>+ Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
<p className="text-xs text-gray-400">
|
||||
<strong>Hide</strong> removes a builtin field from forms while preserving its data.
|
||||
<strong className="ml-1">✕</strong> permanently removes a custom field (data in existing records is not deleted).
|
||||
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 */}
|
||||
|
||||
Reference in New Issue
Block a user