feat(template): per-experiment daily record template — rename/add/hide fields, custom_fields JSONB

This commit is contained in:
Experiments DB Dev
2026-04-15 13:56:17 -04:00
parent 80fb1c6e27
commit 86a56427b7
10 changed files with 570 additions and 189 deletions
+225
View File
@@ -0,0 +1,225 @@
import React, { useState, useEffect } 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
function toKey(label) {
return label
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
}
function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
const [labelDraft, setLabelDraft] = useState(field.label);
const [labelError, setLabelError] = useState('');
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; }
// 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; }
}
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>
{/* 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}</p>
</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>
)}
</div>
);
}
export default function TemplateEditor({ experimentId, onClose }) {
const [fields, setFields] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const [newLabel, setNewLabel] = useState('');
const [newType, setNewType] = useState('text');
const [addError, setAddError] = useState('');
useEffect(() => {
experimentsApi.getTemplate(experimentId)
.then(setFields)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [experimentId]);
function updateField(updated) {
setFields((prev) => prev.map((f) => f.key === updated.key ? updated : f));
}
function toggleField(key) {
setFields((prev) => prev.map((f) => f.key === key ? { ...f, active: !f.active } : f));
}
function removeField(key) {
setFields((prev) => prev.filter((f) => f.key !== key));
}
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('');
setFields((prev) => [...prev, { key, label: trimmed, type: newType, builtin: false, active: true }]);
setNewLabel('');
setNewType('text');
}
async function save() {
setSaving(true);
setError(null);
try {
await experimentsApi.updateTemplate(experimentId, fields);
setSuccess(true);
setTimeout(() => { setSuccess(false); onClose(fields); }, 800);
} 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) => (
<FieldRow
key={field.key}
field={field}
onChange={updateField}
onToggle={toggleField}
onRemove={removeField}
allKeys={allKeys}
/>
))}
</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" onClick={addField} type="button">+ 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).
</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>
);
}