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 (
{!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 */}
>
)}
);
}