ZDDC/form/js/object.js
ZDDC 7dfedc2342 feat(form): ui:mirrorFrom — reflect a sibling field into a read-only field
The project-rollup forms derive originator from the selected Package
(party folder) server-side, so the field is read-only and was blank
until submit. Add a declarative `ui:mirrorFrom: <sibling>` hint: the
object renderer wires the named sibling's input to the field so the
read-only originator updates live as the user picks a party — the
composing tracking number is visible while filling the form. Display
only; the server stays authoritative via the cascade's folder_fields.

Set `ui:mirrorFrom: party` on originator in the embedded
default-project-{mdl,rsk}.form.yaml. Generic hint, not hardcoded field
names, so operators can reuse it.

Test: form-safety.spec.js — filling the source field updates the
read-only target; the target is not editable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:44:43 -05:00

132 lines
5.2 KiB
JavaScript

(function (app) {
'use strict';
const u = app.modules.util;
function makeObject(schema, ui, path, value, options) {
const fs = u.h('fieldset', { className: 'form-fieldset' });
const label = (ui && ui['ui:title']) || schema.title || options.fieldName;
if (label) {
fs.appendChild(u.h('legend', { className: 'form-fieldset__legend' }, label));
}
if (schema.description) {
fs.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
fs.appendChild(errEl);
const props = schema.properties || {};
const requiredSet = {};
(schema.required || []).forEach(function (n) { requiredSet[n] = true; });
// Resolve render order: ui:order first (with '*' as "everything else"),
// then fall back to declaration order.
const declared = Object.keys(props);
const uiOrder = (ui && ui['ui:order']) || null;
const ordered = [];
const seen = {};
if (uiOrder && Array.isArray(uiOrder)) {
for (let i = 0; i < uiOrder.length; i++) {
const name = uiOrder[i];
if (name === '*') {
for (let j = 0; j < declared.length; j++) {
const dn = declared[j];
if (!seen[dn] && uiOrder.indexOf(dn) < 0) {
ordered.push(dn);
seen[dn] = true;
}
}
} else if (props[name] && !seen[name]) {
ordered.push(name);
seen[name] = true;
}
}
// Append anything declared but not mentioned in ui:order (and no '*' was used).
for (let j = 0; j < declared.length; j++) {
if (!seen[declared[j]]) {
ordered.push(declared[j]);
seen[declared[j]] = true;
}
}
} else {
for (let j = 0; j < declared.length; j++) {
ordered.push(declared[j]);
}
}
const children = {};
const dataObj = (value && typeof value === 'object' && !Array.isArray(value)) ? value : {};
for (let i = 0; i < ordered.length; i++) {
const name = ordered[i];
const childSchema = props[name];
const childUi = (ui && ui[name]) || {};
const childPath = u.ptrPush(path, name);
const childValue = dataObj[name];
const childWidget = app.modules.render.create(childSchema, childUi, childPath, childValue, {
fieldName: u.humanize(name),
required: !!requiredSet[name]
});
children[name] = childWidget;
fs.appendChild(childWidget.el);
}
// Cross-field mirror: a field with `ui:mirrorFrom: <sibling>`
// shows the live value of that sibling. Used by the project-
// rollup forms so the read-only `originator` reflects the
// selected Package (party) — the party folder is the
// originator's source of truth. Display-only: the server is
// still authoritative via the cascade's folder_fields.
for (let i = 0; i < ordered.length; i++) {
const name = ordered[i];
const mirrorFrom = ui && ui[name] && ui[name]['ui:mirrorFrom'];
if (!mirrorFrom || !children[name] || !children[mirrorFrom]) {
continue;
}
const targetInput = children[name].el.querySelector('input, select, textarea');
const sourceInput = children[mirrorFrom].el.querySelector('input, select, textarea');
if (!targetInput || !sourceInput) {
continue;
}
const sync = function () { targetInput.value = sourceInput.value; };
sourceInput.addEventListener('input', sync);
sourceInput.addEventListener('change', sync);
sync(); // initialize from any pre-filled party value
}
return {
el: fs,
path: path,
type: 'object',
read: function () {
const out = {};
const keys = Object.keys(children);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const v = children[k].read();
if (v !== undefined) {
out[k] = v;
}
}
return out;
},
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
const keys = Object.keys(children);
for (let i = 0; i < keys.length; i++) {
children[keys[i]].clearErrors();
}
},
child: function (name) {
return children[name] || null;
}
};
}
app.modules.object = { makeObject: makeObject };
})(window.formApp);