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>
This commit is contained in:
ZDDC 2026-05-21 15:44:43 -05:00
parent 9341c47937
commit 7dfedc2342
4 changed files with 66 additions and 0 deletions

View file

@ -71,6 +71,29 @@
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,

View file

@ -91,6 +91,39 @@ test.describe('form/ — safety check-in renderer', () => {
await expect(page.locator('#table-title')).toContainText('Safety Check-In');
});
test('ui:mirrorFrom reflects a sibling field into a read-only field', async ({ page }) => {
// The project-rollup forms use this so the read-only originator
// shows the selected Package (party) — the party folder is the
// originator's source of truth.
await loadFormWithContext(page, {
title: 'Rollup deliverable',
schema: {
type: 'object',
required: ['party'],
properties: {
party: { type: 'string', title: 'Package' },
originator: { type: 'string', title: 'Originator', readOnly: true },
},
},
ui: { originator: { 'ui:mirrorFrom': 'party' } },
data: null,
submitUrl: '/test/rollup.form.html',
});
await page.waitForFunction(
() => document.getElementById('form-root') && document.getElementById('form-root').children.length > 0,
null,
{ timeout: 5000 },
);
const inputs = page.locator('#form-root input[type="text"]');
await expect(inputs).toHaveCount(2);
const party = inputs.nth(0);
const originator = inputs.nth(1);
await expect(originator).not.toBeEditable(); // read-only
await party.fill('0330C1');
await expect(originator).toHaveValue('0330C1');
});
test('add/remove hazard rows works', async ({ page }) => {
await loadFormWithContext(page, {
schema: SAFETY_SCHEMA,

View file

@ -122,5 +122,10 @@ schema:
title: Previous SHA
readOnly: true
ui:
# originator is server-derived from the selected Package (party
# folder); mirror the party value into its read-only field so the
# composing tracking number is visible as the user fills the form.
originator:
ui:mirrorFrom: party
notes:
ui:widget: textarea

View file

@ -152,6 +152,11 @@ schema:
title: Previous SHA
readOnly: true
ui:
# originator is server-derived from the selected Package (party
# folder); mirror the party value into its read-only field so the
# composing tracking number is visible as the user fills the form.
originator:
ui:mirrorFrom: party
description:
ui:widget: textarea
mitigation: