feat(client): consume server-stamped audit body + render labels/readonly

tables/js/save.js: on 200/201, parse the YAML body the server now
echoes back (it carries the just-stamped audit fields) and replace
row.data with it. The previous local-merge code falsified the table
view — server stamping changes bytes the client doesn't predict
(revision, created_by, previous_sha), and the merge would have shown
stale values until a fresh GET. Falls back to local merge when the
response has no body (non-record write or older server).

form/js/widgets.js:
- honor schema.readOnly (alongside the existing ui:readonly UI
  override) so cascade-locked + audit fields render as disabled
- read x-labels for enums and render "<code> — <label>" in both
  <select> and radio variants (server-injected from
  field_codes:codes for human-readable dropdowns)
- propagate schema.pattern to <input pattern> as a UX hint
  (authoritative validation runs server-side via WriteWithHistory)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-19 10:00:10 -05:00
parent d947f616d1
commit 83c3b332d5
2 changed files with 46 additions and 5 deletions

View file

@ -44,7 +44,16 @@
const help = (ui && ui['ui:help']) || '';
const placeholder = (ui && ui['ui:placeholder']) || '';
const widget = (ui && ui['ui:widget']) || '';
const readonly = !!(ui && ui['ui:readonly']);
// readonly is honored from either source: an explicit UI override
// (ui:readonly: true) or the schema's readOnly field. The latter
// is set by the server when augmenting from cascade-locked
// records: entries and for audit fields declared readOnly in the
// *.form.yaml.
const readonly = !!(schema.readOnly) || !!(ui && ui['ui:readonly']);
// x-labels: { code → label } turns a bare enum into a labeled
// dropdown ("ACM — Acme Inc" rather than just "ACM"). Injected
// by the server from the cascade's field_codes:codes map.
const labels = (schema && schema['x-labels']) || null;
const autofocus = !!(ui && ui['ui:autofocus']);
let input;
@ -90,17 +99,22 @@
if (widget === 'radio') {
input = u.h('div', { className: 'form-field__radio-group' });
opts.forEach(function (opt, idx) {
const codeStr = String(opt);
const radioId = id + '-' + idx;
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: String(opt) });
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: codeStr });
if (value === opt) {
radio.checked = true;
}
if (readonly) {
radio.disabled = true;
}
let displayText = codeStr;
if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) {
displayText = codeStr + ' — ' + labels[codeStr];
}
const lbl = u.h('label', { for: radioId });
lbl.appendChild(radio);
lbl.appendChild(document.createTextNode(' ' + String(opt)));
lbl.appendChild(document.createTextNode(' ' + displayText));
input.appendChild(lbl);
});
read = function () {
@ -113,7 +127,12 @@
input.appendChild(u.h('option', { value: '' }, '— select —'));
}
opts.forEach(function (opt) {
const o = u.h('option', { value: String(opt) }, String(opt));
const codeStr = String(opt);
let displayText = codeStr;
if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) {
displayText = codeStr + ' — ' + labels[codeStr];
}
const o = u.h('option', { value: codeStr }, displayText);
if (value === opt) {
o.selected = true;
}
@ -184,6 +203,12 @@
if (autofocus) {
input.autofocus = true;
}
// Schema-driven HTML pattern attribute. Used as a UX hint
// only — authoritative validation runs server-side via the
// cascade's field_codes.
if (schema.pattern && input.tagName === 'INPUT') {
input.pattern = schema.pattern;
}
read = function () {
return input.value === '' ? undefined : input.value;
};

View file

@ -237,7 +237,23 @@
// Success: clear drafts + invalid marks, capture new ETag.
const newEtag = resp.headers.get('ETag');
if (newEtag) row.etag = newEtag.replace(/"/g, '');
row.data = merged;
// For record-typed writes the server echoes the stamped
// YAML (with server-managed audit fields) back as the
// response body — parse it and overwrite row.data so the
// table sees the same bytes that just landed on disk.
// Falls back to the local merge when the server didn't
// echo a body (non-record write or older server).
let serverData = null;
const ct = (resp.headers.get('Content-Type') || '').toLowerCase();
if (ct.includes('yaml') && window.jsyaml) {
try {
const text = await resp.text();
if (text && text.trim()) serverData = window.jsyaml.load(text);
} catch (e) {
console.warn('[tables] server response YAML parse failed; using local merge', e);
}
}
row.data = serverData || merged;
delete app.state.drafts[rowId];
clearCellInvalid(rowId);
setRowState(rowId, '');