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 help = (ui && ui['ui:help']) || '';
|
||||||
const placeholder = (ui && ui['ui:placeholder']) || '';
|
const placeholder = (ui && ui['ui:placeholder']) || '';
|
||||||
const widget = (ui && ui['ui:widget']) || '';
|
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']);
|
const autofocus = !!(ui && ui['ui:autofocus']);
|
||||||
|
|
||||||
let input;
|
let input;
|
||||||
|
|
@ -90,17 +99,22 @@
|
||||||
if (widget === 'radio') {
|
if (widget === 'radio') {
|
||||||
input = u.h('div', { className: 'form-field__radio-group' });
|
input = u.h('div', { className: 'form-field__radio-group' });
|
||||||
opts.forEach(function (opt, idx) {
|
opts.forEach(function (opt, idx) {
|
||||||
|
const codeStr = String(opt);
|
||||||
const radioId = id + '-' + idx;
|
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) {
|
if (value === opt) {
|
||||||
radio.checked = true;
|
radio.checked = true;
|
||||||
}
|
}
|
||||||
if (readonly) {
|
if (readonly) {
|
||||||
radio.disabled = true;
|
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 });
|
const lbl = u.h('label', { for: radioId });
|
||||||
lbl.appendChild(radio);
|
lbl.appendChild(radio);
|
||||||
lbl.appendChild(document.createTextNode(' ' + String(opt)));
|
lbl.appendChild(document.createTextNode(' ' + displayText));
|
||||||
input.appendChild(lbl);
|
input.appendChild(lbl);
|
||||||
});
|
});
|
||||||
read = function () {
|
read = function () {
|
||||||
|
|
@ -113,7 +127,12 @@
|
||||||
input.appendChild(u.h('option', { value: '' }, '— select —'));
|
input.appendChild(u.h('option', { value: '' }, '— select —'));
|
||||||
}
|
}
|
||||||
opts.forEach(function (opt) {
|
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) {
|
if (value === opt) {
|
||||||
o.selected = true;
|
o.selected = true;
|
||||||
}
|
}
|
||||||
|
|
@ -184,6 +203,12 @@
|
||||||
if (autofocus) {
|
if (autofocus) {
|
||||||
input.autofocus = true;
|
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 () {
|
read = function () {
|
||||||
return input.value === '' ? undefined : input.value;
|
return input.value === '' ? undefined : input.value;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -237,7 +237,23 @@
|
||||||
// Success: clear drafts + invalid marks, capture new ETag.
|
// Success: clear drafts + invalid marks, capture new ETag.
|
||||||
const newEtag = resp.headers.get('ETag');
|
const newEtag = resp.headers.get('ETag');
|
||||||
if (newEtag) row.etag = newEtag.replace(/"/g, '');
|
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];
|
delete app.state.drafts[rowId];
|
||||||
clearCellInvalid(rowId);
|
clearCellInvalid(rowId);
|
||||||
setRowState(rowId, '');
|
setRowState(rowId, '');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue