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:
parent
d947f616d1
commit
83c3b332d5
2 changed files with 46 additions and 5 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, '');
|
||||
|
|
|
|||
Loading…
Reference in a new issue