feat(template): per-experiment daily record template — rename/add/hide fields, custom_fields JSONB
This commit is contained in:
@@ -5,42 +5,48 @@ import FormField, { Input, Textarea } from './ui/FormField';
|
||||
import Alert from './ui/Alert';
|
||||
import { dailyStatusesApi } from '../api/client';
|
||||
|
||||
export default function DailyStatusForm({ existing, animalId, onSuccess, onCancel }) {
|
||||
const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
||||
|
||||
/** Extract the value for a field from a status record */
|
||||
function getValue(status, field) {
|
||||
if (!status) return '';
|
||||
if (field.builtin) return status[field.key] ?? '';
|
||||
return status.custom_fields?.[field.key] ?? '';
|
||||
}
|
||||
|
||||
export default function DailyStatusForm({ existing, animalId, template = [], onSuccess, onCancel }) {
|
||||
const today = format(new Date(), 'yyyy-MM-dd');
|
||||
const [fields, setFields] = useState({
|
||||
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
|
||||
experiment_description: existing?.experiment_description ?? '',
|
||||
vitals: existing?.vitals ?? '',
|
||||
treatment: existing?.treatment ?? '',
|
||||
notes: existing?.notes ?? '',
|
||||
});
|
||||
|
||||
// Active fields from template (date is always first and handled separately)
|
||||
const activeFields = template.filter((f) => f.active);
|
||||
|
||||
function buildInitialValues() {
|
||||
const vals = { date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today };
|
||||
for (const f of activeFields) {
|
||||
vals[f.key] = getValue(existing, f);
|
||||
}
|
||||
return vals;
|
||||
}
|
||||
|
||||
const [values, setValues] = useState(buildInitialValues);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFields({
|
||||
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
|
||||
experiment_description: existing?.experiment_description ?? '',
|
||||
vitals: existing?.vitals ?? '',
|
||||
treatment: existing?.treatment ?? '',
|
||||
notes: existing?.notes ?? '',
|
||||
});
|
||||
setValues(buildInitialValues());
|
||||
setErrors({});
|
||||
setApiError(null);
|
||||
}, [existing]);
|
||||
}, [existing, template]);
|
||||
|
||||
const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value }));
|
||||
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
|
||||
|
||||
function validate() {
|
||||
const e = {};
|
||||
if (!fields.date) {
|
||||
if (!values.date) {
|
||||
e.date = 'Date is required';
|
||||
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(fields.date)) {
|
||||
e.date = 'Date must be in YYYY-MM-DD format';
|
||||
} else {
|
||||
const d = new Date(fields.date);
|
||||
if (isNaN(d.getTime())) e.date = 'Invalid date';
|
||||
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(values.date) || isNaN(new Date(values.date).getTime())) {
|
||||
e.date = 'Date must be a valid YYYY-MM-DD date';
|
||||
}
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
@@ -52,16 +58,26 @@ export default function DailyStatusForm({ existing, animalId, onSuccess, onCance
|
||||
setLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const payload = {
|
||||
date: fields.date,
|
||||
experiment_description: fields.experiment_description || null,
|
||||
vitals: fields.vitals || null,
|
||||
treatment: fields.treatment || null,
|
||||
notes: fields.notes || null,
|
||||
};
|
||||
// Separate builtin values from custom_fields
|
||||
const builtin = {};
|
||||
const custom = {};
|
||||
for (const f of activeFields) {
|
||||
const val = values[f.key] || null;
|
||||
if (f.builtin) builtin[f.key] = val;
|
||||
else custom[f.key] = val;
|
||||
}
|
||||
|
||||
// Merge with any pre-existing custom_fields not in current active template
|
||||
// (so we don't wipe data for fields that were hidden after the entry was made)
|
||||
const existingCustom = existing?.custom_fields ?? {};
|
||||
const mergedCustom = { ...existingCustom, ...custom };
|
||||
|
||||
const payload = { date: values.date, ...builtin, custom_fields: mergedCustom };
|
||||
|
||||
const result = existing
|
||||
? await dailyStatusesApi.update(existing.id, payload)
|
||||
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
|
||||
|
||||
onSuccess(result);
|
||||
} catch (err) {
|
||||
setApiError(err);
|
||||
@@ -73,66 +89,44 @@ export default function DailyStatusForm({ existing, animalId, onSuccess, onCance
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
{apiError && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={apiError.message}
|
||||
details={apiError.details}
|
||||
onDismiss={() => setApiError(null)}
|
||||
/>
|
||||
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
|
||||
)}
|
||||
|
||||
{/* Date — always present */}
|
||||
<FormField label="Date" name="date" error={errors.date} required>
|
||||
<Input
|
||||
type="date"
|
||||
name="date"
|
||||
value={fields.date}
|
||||
onChange={set('date')}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Experiment Description" name="experiment_description">
|
||||
<Textarea
|
||||
name="experiment_description"
|
||||
value={fields.experiment_description}
|
||||
onChange={set('experiment_description')}
|
||||
placeholder="Describe the experimental conditions…"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Vitals" name="vitals">
|
||||
<Input
|
||||
name="vitals"
|
||||
value={fields.vitals}
|
||||
onChange={set('vitals')}
|
||||
placeholder="e.g. HR 72bpm, Temp 37.2°C, Weight 280g"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Treatment" name="treatment">
|
||||
<Input
|
||||
name="treatment"
|
||||
value={fields.treatment}
|
||||
onChange={set('treatment')}
|
||||
placeholder="e.g. Drug X — 10mg/kg oral"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Notes" name="notes">
|
||||
<Textarea
|
||||
name="notes"
|
||||
value={fields.notes}
|
||||
onChange={set('notes')}
|
||||
placeholder="Any additional observations…"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Input type="date" name="date" value={values.date} onChange={set('date')} required disabled={loading} />
|
||||
</FormField>
|
||||
|
||||
{/* Dynamic fields from template */}
|
||||
{activeFields.map((field) => (
|
||||
<FormField key={field.key} label={field.label} name={field.key}>
|
||||
{field.type === 'textarea' ? (
|
||||
<Textarea
|
||||
name={field.key}
|
||||
value={values[field.key] ?? ''}
|
||||
onChange={set(field.key)}
|
||||
disabled={loading}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
name={field.key}
|
||||
value={values[field.key] ?? ''}
|
||||
onChange={set(field.key)}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
))}
|
||||
|
||||
{activeFields.length === 0 && (
|
||||
<p className="text-sm text-gray-400 italic text-center py-2">
|
||||
No fields configured. Edit the experiment's Record Template to add fields.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{existing ? 'Save Changes' : 'Log Status'}
|
||||
</Button>
|
||||
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
|
||||
<Button type="submit" loading={loading}>{existing ? 'Save Changes' : 'Log Status'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user