merge: stable template fieldIds into main
This commit is contained in:
@@ -1,25 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* The default daily-record template applied to every new experiment
|
* The default daily-record template applied to every new experiment.
|
||||||
* (and returned as a fallback when an experiment's template is empty).
|
|
||||||
*
|
*
|
||||||
* `builtin: true` — value lives in the named column of daily_statuses
|
* Builtin fields have FIXED fieldIds so they are stable across all experiments
|
||||||
* `builtin: false` — value lives in daily_statuses.custom_fields JSON
|
* and can never be confused with user-created fields, even if a user creates a
|
||||||
* `active: false` — field is hidden (not deleted; data preserved)
|
* custom field with the same label.
|
||||||
|
*
|
||||||
|
* `fieldId` — stable UUID used as the storage key; never changes even if
|
||||||
|
* the field is renamed, reordered, or temporarily removed
|
||||||
|
* `key` — human-readable slug (display only, not used for storage)
|
||||||
|
* `builtin` — true: value stored in the dedicated column of daily_statuses
|
||||||
|
* false: value stored in daily_statuses.custom_fields[fieldId]
|
||||||
|
* `active` — false: field is hidden (data preserved)
|
||||||
*/
|
*/
|
||||||
const DEFAULT_TEMPLATE = [
|
const DEFAULT_TEMPLATE = [
|
||||||
{ key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true },
|
{ fieldId: '00000000-0000-0000-0000-000000000001', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true },
|
||||||
{ key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
|
{ fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
|
||||||
{ key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true },
|
{ fieldId: '00000000-0000-0000-0000-000000000003', key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true },
|
||||||
{ key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true },
|
{ fieldId: '00000000-0000-0000-0000-000000000004', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
* Resolve the effective template for an experiment.
|
|
||||||
* If the stored template is empty (new experiment), return the default.
|
|
||||||
*/
|
|
||||||
function resolveTemplate(stored) {
|
function resolveTemplate(stored) {
|
||||||
if (!Array.isArray(stored) || stored.length === 0) return DEFAULT_TEMPLATE;
|
if (!Array.isArray(stored) || stored.length === 0) return DEFAULT_TEMPLATE;
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { DEFAULT_TEMPLATE, resolveTemplate };
|
module.exports = { DEFAULT_TEMPLATE, resolveTemplate, UUID_RE };
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ const { body } = require('express-validator');
|
|||||||
const prisma = require('../lib/prisma');
|
const prisma = require('../lib/prisma');
|
||||||
const auditMiddleware = require('../middleware/auditMiddleware');
|
const auditMiddleware = require('../middleware/auditMiddleware');
|
||||||
const { validateRequest } = require('../middleware/errorHandler');
|
const { validateRequest } = require('../middleware/errorHandler');
|
||||||
const { resolveTemplate, DEFAULT_TEMPLATE } = require('../lib/defaultTemplate');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { resolveTemplate, DEFAULT_TEMPLATE, UUID_RE } = require('../lib/defaultTemplate');
|
||||||
|
|
||||||
const experimentValidation = [
|
const experimentValidation = [
|
||||||
body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'),
|
body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'),
|
||||||
@@ -11,6 +12,7 @@ const experimentValidation = [
|
|||||||
|
|
||||||
// ── Template validation helpers ────────────────────────────────────────────────
|
// ── Template validation helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BUILTIN_FIELD_IDS = new Set(DEFAULT_TEMPLATE.map((f) => f.fieldId));
|
||||||
const BUILTIN_KEYS = new Set(DEFAULT_TEMPLATE.map((f) => f.key));
|
const BUILTIN_KEYS = new Set(DEFAULT_TEMPLATE.map((f) => f.key));
|
||||||
const VALID_TYPES = new Set(['text', 'textarea']);
|
const VALID_TYPES = new Set(['text', 'textarea']);
|
||||||
|
|
||||||
@@ -18,23 +20,37 @@ function validateTemplate(template) {
|
|||||||
if (!Array.isArray(template)) return 'template must be an array';
|
if (!Array.isArray(template)) return 'template must be an array';
|
||||||
if (template.length > 50) return 'template may have at most 50 fields';
|
if (template.length > 50) return 'template may have at most 50 fields';
|
||||||
|
|
||||||
const keys = new Set();
|
const seenFieldIds = new Set();
|
||||||
|
const seenKeys = new Set();
|
||||||
|
|
||||||
for (const field of template) {
|
for (const field of template) {
|
||||||
|
// ── fieldId ────────────────────────────────────────────────────────────────
|
||||||
|
if (!field.fieldId || !UUID_RE.test(field.fieldId))
|
||||||
|
return `each field must have a valid UUID fieldId (got: ${field.fieldId})`;
|
||||||
|
if (seenFieldIds.has(field.fieldId))
|
||||||
|
return `duplicate fieldId "${field.fieldId}"`;
|
||||||
|
seenFieldIds.add(field.fieldId);
|
||||||
|
|
||||||
|
// Builtin fields must keep their original fieldIds
|
||||||
|
if (BUILTIN_KEYS.has(field.key) && !BUILTIN_FIELD_IDS.has(field.fieldId))
|
||||||
|
return `builtin field "${field.key}" must keep its original fieldId`;
|
||||||
|
|
||||||
|
// ── key ────────────────────────────────────────────────────────────────────
|
||||||
if (!field.key || typeof field.key !== 'string') return 'each field must have a string key';
|
if (!field.key || typeof field.key !== 'string') return 'each field must have a string key';
|
||||||
if (!/^[a-z0-9_]+$/.test(field.key)) return `key "${field.key}" must be lowercase alphanumeric/underscore only`;
|
if (!/^[a-z0-9_]+$/.test(field.key)) return `key "${field.key}" must be lowercase alphanumeric/underscore only`;
|
||||||
if (field.key.length > 64) return `key "${field.key}" must be ≤64 characters`;
|
if (field.key.length > 64) return `key "${field.key}" must be ≤64 characters`;
|
||||||
if (keys.has(field.key)) return `duplicate key "${field.key}"`;
|
if (seenKeys.has(field.key)) return `duplicate key "${field.key}"`;
|
||||||
keys.add(field.key);
|
seenKeys.add(field.key);
|
||||||
|
|
||||||
|
// ── label ──────────────────────────────────────────────────────────────────
|
||||||
if (!field.label || typeof field.label !== 'string' || !field.label.trim())
|
if (!field.label || typeof field.label !== 'string' || !field.label.trim())
|
||||||
return `field "${field.key}" must have a non-empty label`;
|
return `field "${field.key}" must have a non-empty label`;
|
||||||
if (field.label.length > 128) return `label for "${field.key}" must be ≤128 characters`;
|
if (field.label.length > 128) return `label for "${field.key}" must be ≤128 characters`;
|
||||||
|
|
||||||
|
// ── type / active / builtin ────────────────────────────────────────────────
|
||||||
if (!VALID_TYPES.has(field.type)) return `field "${field.key}" type must be "text" or "textarea"`;
|
if (!VALID_TYPES.has(field.type)) return `field "${field.key}" type must be "text" or "textarea"`;
|
||||||
|
|
||||||
if (typeof field.active !== 'boolean') return `field "${field.key}" must have a boolean active flag`;
|
if (typeof field.active !== 'boolean') return `field "${field.key}" must have a boolean active flag`;
|
||||||
|
|
||||||
// Builtin keys cannot change their builtin status
|
|
||||||
if (BUILTIN_KEYS.has(field.key) && field.builtin !== true)
|
if (BUILTIN_KEYS.has(field.key) && field.builtin !== true)
|
||||||
return `field "${field.key}" is a builtin field and cannot be made non-builtin`;
|
return `field "${field.key}" is a builtin field and cannot be made non-builtin`;
|
||||||
if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false)
|
if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false)
|
||||||
@@ -43,6 +59,18 @@ function validateTemplate(template) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back-fill any missing fieldIds (handles records saved before this feature).
|
||||||
|
* Builtin fields get their canonical IDs; custom fields get fresh UUIDs.
|
||||||
|
*/
|
||||||
|
function ensureFieldIds(template) {
|
||||||
|
const builtinById = Object.fromEntries(DEFAULT_TEMPLATE.map((f) => [f.key, f.fieldId]));
|
||||||
|
return template.map((f) => ({
|
||||||
|
...f,
|
||||||
|
fieldId: f.fieldId || (f.builtin ? builtinById[f.key] : uuidv4()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Experiment CRUD ────────────────────────────────────────────────────────────
|
// ── Experiment CRUD ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /api/experiments
|
// GET /api/experiments
|
||||||
@@ -64,8 +92,7 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
include: { animals: true },
|
include: { animals: true },
|
||||||
});
|
});
|
||||||
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
|
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
|
||||||
// Always return resolved template (never raw [])
|
experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
|
||||||
experiment.template = resolveTemplate(experiment.template);
|
|
||||||
res.json(experiment);
|
res.json(experiment);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -78,7 +105,7 @@ router.post('/', experimentValidation, validateRequest, async (req, res, next) =
|
|||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
data: { table_name: 'experiments', record_id: experiment.id, action: 'CREATE', changes: { before: null, after: experiment } },
|
data: { table_name: 'experiments', record_id: experiment.id, action: 'CREATE', changes: { before: null, after: experiment } },
|
||||||
});
|
});
|
||||||
experiment.template = resolveTemplate(experiment.template);
|
experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
|
||||||
res.status(201).json(experiment);
|
res.status(201).json(experiment);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -93,7 +120,7 @@ router.put(
|
|||||||
try {
|
try {
|
||||||
const { title } = req.body;
|
const { title } = req.body;
|
||||||
const experiment = await prisma.experiment.update({ where: { id: req.params.id }, data: { title } });
|
const experiment = await prisma.experiment.update({ where: { id: req.params.id }, data: { title } });
|
||||||
experiment.template = resolveTemplate(experiment.template);
|
experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
|
||||||
res.json(experiment);
|
res.json(experiment);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
}
|
}
|
||||||
@@ -121,7 +148,7 @@ router.get('/:id/template', async (req, res, next) => {
|
|||||||
select: { id: true, template: true },
|
select: { id: true, template: true },
|
||||||
});
|
});
|
||||||
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
|
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
|
||||||
res.json(resolveTemplate(experiment.template));
|
res.json(ensureFieldIds(resolveTemplate(experiment.template)));
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,7 +162,7 @@ router.put('/:id/template', async (req, res, next) => {
|
|||||||
const validationError = validateTemplate(template);
|
const validationError = validateTemplate(template);
|
||||||
if (validationError) return res.status(422).json({ error: validationError });
|
if (validationError) return res.status(422).json({ error: validationError });
|
||||||
|
|
||||||
const before = resolveTemplate(experiment.template);
|
const before = ensureFieldIds(resolveTemplate(experiment.template));
|
||||||
const updated = await prisma.experiment.update({
|
const updated = await prisma.experiment.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
data: { template },
|
data: { template },
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import { dailyStatusesApi } from '../api/client';
|
|||||||
|
|
||||||
const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
||||||
|
|
||||||
/** Extract the value for a field from a status record */
|
/** Extract the value for a field from a status record.
|
||||||
|
* Custom fields are keyed by fieldId (stable UUID), not by key (display slug). */
|
||||||
function getValue(status, field) {
|
function getValue(status, field) {
|
||||||
if (!status) return '';
|
if (!status) return '';
|
||||||
if (field.builtin) return status[field.key] ?? '';
|
if (field.builtin) return status[field.key] ?? '';
|
||||||
return status.custom_fields?.[field.key] ?? '';
|
return status.custom_fields?.[field.fieldId] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DailyStatusForm({ existing, animalId, template = [], onSuccess, onCancel }) {
|
export default function DailyStatusForm({ existing, animalId, template = [], onSuccess, onCancel }) {
|
||||||
@@ -63,12 +64,17 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
|
|||||||
const custom = {};
|
const custom = {};
|
||||||
for (const f of activeFields) {
|
for (const f of activeFields) {
|
||||||
const val = values[f.key] || null;
|
const val = values[f.key] || null;
|
||||||
if (f.builtin) builtin[f.key] = val;
|
if (f.builtin) {
|
||||||
else custom[f.key] = val;
|
builtin[f.key] = val;
|
||||||
|
} else {
|
||||||
|
// Always key by fieldId so renaming or recreating a field never
|
||||||
|
// collides with or orphans data from a different field.
|
||||||
|
custom[f.fieldId] = val;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge with any pre-existing custom_fields not in current active template
|
// Preserve custom_fields for fields not in the current active template
|
||||||
// (so we don't wipe data for fields that were hidden after the entry was made)
|
// (e.g. a field was hidden after this record was created).
|
||||||
const existingCustom = existing?.custom_fields ?? {};
|
const existingCustom = existing?.custom_fields ?? {};
|
||||||
const mergedCustom = { ...existingCustom, ...custom };
|
const mergedCustom = { ...existingCustom, ...custom };
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,14 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
|
|||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
|
||||||
/>
|
/>
|
||||||
{labelError && <p className="text-xs text-red-500 mt-0.5">{labelError}</p>}
|
{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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Type badge */}
|
{/* Type badge */}
|
||||||
@@ -133,7 +140,8 @@ export default function TemplateEditor({ experimentId, onClose }) {
|
|||||||
if (!key) { setAddError('Name must contain at least one alphanumeric character'); return; }
|
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; }
|
if (fields.some((f) => f.key === key)) { setAddError(`A field with key "${key}" already exists`); return; }
|
||||||
setAddError('');
|
setAddError('');
|
||||||
setFields((prev) => [...prev, { key, label: trimmed, type: newType, builtin: false, active: true }]);
|
const fieldId = crypto.randomUUID();
|
||||||
|
setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true }]);
|
||||||
setNewLabel('');
|
setNewLabel('');
|
||||||
setNewType('text');
|
setNewType('text');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import DailyStatusForm from '../components/DailyStatusForm';
|
|||||||
import Alert from '../components/ui/Alert';
|
import Alert from '../components/ui/Alert';
|
||||||
import AuditLogSection from '../components/AuditLogSection';
|
import AuditLogSection from '../components/AuditLogSection';
|
||||||
|
|
||||||
/** Render the value for a template field from a status record */
|
/** Read the value for a template field from a status record.
|
||||||
|
* Custom fields are keyed by fieldId, not by the display key. */
|
||||||
function getFieldValue(status, field) {
|
function getFieldValue(status, field) {
|
||||||
if (field.builtin) return status[field.key];
|
if (field.builtin) return status[field.key];
|
||||||
return status.custom_fields?.[field.key];
|
return status.custom_fields?.[field.fieldId];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AnimalDetail() {
|
export default function AnimalDetail() {
|
||||||
|
|||||||
Reference in New Issue
Block a user