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 ( {value} ); } // ── 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 (

Quick-fill tags

{!disabled && ( <>
{(field.tags ?? []).length === 0 && ( No tags yet. )} {(field.tags ?? []).map((t) => ( onRemoveTag(field.fieldId, t)} /> ))}
{ setDraft(e.target.value); setErr(''); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }} /> {err &&

{err}

}
)} {disabled && (

Tagging is disabled for this field. Existing tags are preserved.

)}
); } // ── 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 (
onDragStart(index)} onDragEnter={() => onDragEnter(index)} onDragEnd={onDragEnd} onDragOver={(e) => e.preventDefault()} > {/* Main row */}
{/* Drag handle */} {/* Label input */}
{ setLabelDraft(e.target.value); setLabelError(''); }} onBlur={commitLabel} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }} /> {labelError &&

{labelError}

}

key: {field.key} {field.fieldId && ( · id: {field.fieldId.slice(0, 8)}… )}

{/* Type selector */} {/* Width (text fields only) */} {field.type === 'text' && ( )} {/* Abbreviation */} onChange({ ...field, abbr: e.target.value })} /> {/* Unit */} onChange({ ...field, unit: e.target.value })} /> {/* Tags toggle */} {/* Hide / Show */} {/* Remove (custom fields only) */} {!field.builtin && ( )}
{/* Tag panel (collapsible) */} {showTags && ( )}
); } // ── 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 (
{loading &&

Loading template…

} {error && setError(null)} />} {success && } {!loading && ( <> {/* Field list */}
{fields.length === 0 && (

No fields yet. Add one below.

)} {fields.map((field, index) => ( ))}
{/* Add new field */}

Add New Field

{ setNewLabel(e.target.value); setAddError(''); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addField(); } }} /> {addError &&

{addError}

}

Drag to reorder · Hide removes a builtin field from forms while preserving its data · 🏷 manages quick-fill tags

{/* Actions */}
)}
); }