From 102372593e2b4ae0458f101bc8d85db50f606967 Mon Sep 17 00:00:00 2001 From: yaraslau Date: Thu, 24 Sep 2026 22:28:56 +0200 Subject: [PATCH 1/5] forms(qml): a host slot can draw a std::vector member, and the form encodes its rows A collection of objects has no built-in control: the comma-separated array control encodes strings, so DynamicForm reports the member unrepresentable and a form that must carry it never becomes ready. That was right for the built-in controls, and it left a host with nowhere to go -- registering a grid slot for the member changed nothing, because the slot could only hand `setValue` a text the array encoder then split on commas, and the slot was never told what the row type's members are. When a registered slot claims a top-level `std::vector` member: - `fields` describes the row type with the same descriptor builder the action uses (`describeObject`, depth-limited to one level so a self-referential row type cannot loop) and puts it on the field as `itemFields`: label, unit, decimals, readOnly, required and the kind flags, in x-order order -- the grid's columns. `isObjectArray` and `claimedBySlot` say which case the descriptor is. - `unrepresentable` is "" for it, because an encoding now exists. - The slot writes rows as `{member: cellText}` objects, where a cell holds what that member's built-in control would hold. `encodeObjectArray` encodes every cell with `encodeFieldText` -- `fieldJsonLiteral`'s body, lifted to take an explicit text and unit index -- so a `Quantity` cell is an exact {num,den,dp}, an over-precise one is refused rather than rounded, a blank optional cell is omitted and a blank required one keeps the form unready. Slots also gain four optional members, each assigned only when the slot declares it, so every existing slot is untouched: `fieldText` (a binding to the retained text -- until now a slot never learned a value it had not written itself, so prefill, `resetFields()` and a tab rebuild all left it stale), `rows`, `setRows(rows)` and `form`. With `fieldText`, CollectionView's row prefill reaches a slot-overridden scalar field too, which views.md listed as a limitation. Nothing changes without a slot: tst_DynamicFormNestedAggregate.qml is untouched and green. The new tst_DynamicFormObjectArraySlot.qml (18 cases) asserts submitted bodies; replacing the cell encoder with plain string quoting reddens 3 of them. forms_qml_logic: 335 passed on MSVC 14.51 / Qt 6.11.1. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 14 + docs/spec/forms/forms.md | 75 ++++- docs/spec/forms/views.md | 11 +- src/qt/forms/qml/CollectionView.qml | 7 +- src/qt/forms/qml/DynamicForm.qml | 172 ++++++++++- .../tests/tst_DynamicFormObjectArraySlot.qml | 288 ++++++++++++++++++ 6 files changed, 546 insertions(+), 21 deletions(-) create mode 100644 src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a328e207..9efe05638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,20 @@ API surface). `invalid` states (`DynamicForm.fieldInvalid(name)`); the status line's text is now `DynamicForm.statusText`. With nothing registered, every form renders as before. See `docs/spec/forms/forms.md`, "Chrome slots". +- **A host slot can draw a `std::vector` member, and the form encodes it.** + A collection of objects had no built-in control and was reported + unrepresentable, so a form carrying one could not be submitted whatever the + host drew. When a `SlotRegistry` slot claims such a (top-level) member, + `DynamicForm` now describes the row type as `field.itemFields` (the grid's + columns: label, unit, decimals, read-only, required, kind flags), accepts the + rows as `{member: cellText}` objects, and encodes every cell with the encoder + the same member gets at the top level (`encodeFieldText`) — a `Quantity` cell + is exact, an over-precise one is refused, a blank required cell keeps the form + unready. Slots gain four optional members, assigned only when declared: + `fieldText` (the retained value, kept current through prefill, reset and tab + rebuilds), `rows`, `setRows(rows)` and `form`. Without a slot nothing changes. + See `docs/spec/forms/forms.md`, "Collections of objects — a host slot draws + them". - **`FieldMeta::unit` / `FieldMeta::decimals` — a display unit and precision for a plain `double`/`float`/integral member.** A DTO holding lab readings as diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index a8104d75f..49e8d2c78 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -941,6 +941,52 @@ encodes to a genuine empty array `[]`, not `null`; a `required` array field is satisfied by engagement (non-blank text), not by having at least one surviving entry. +### Collections of objects — a host slot draws them + +A `std::vector` member is `{"type": "array", "items": {"$ref": +"#/$defs/Row"}}` (or `items` inlined, for a row type used once), and the row +type's `$def` is annotated like any object schema — `x-order`, `title`, +`required`, and every `FieldMeta` key its own `fieldMetadata` declares (see +[Nested aggregates](#nested-aggregates-recursive-cycle-safe)). The built-in +controls cannot collect it: the comma-separated array control encodes strings, +so the member is [unrepresentable](#what-ready-claims) and a form that must +carry it is not ready. + +**A host [slot](#theming--component-override-registry) that claims such a +member makes it representable.** The renderer describes the row type the way +it describes the action, and hands the result to the slot: + +| Field descriptor key | Meaning | +|---|---| +| `isObjectArray` | `true` for an array whose `items` resolve to an object schema with `properties`. | +| `itemFields` | The row type's member descriptors, in `x-order` order — the same shape as a top-level entry of `fields` (`name`, `label`, `unit`, `decimals`, `readOnly`, `hidden`, `required`, `isQuantity`/`isNumber`/`isInteger`/`isBoolean`/`isEnum`/`enumOptions`, …): a grid's columns. Filled for a top-level collection only; one level is described, never more, so a self-referential row type cannot loop. | +| `claimedBySlot` | `true` when a registered slot resolves for this member. `unrepresentable` is then `""`. | + +The slot edits the rows as **cell texts** — `setRows([{sieve: "31.5", +passing: "100.0"}, …])`, or `setValue` with the same array as JSON text — where +each cell holds what the built-in control for that member would hold: the typed +digits of a number or `Quantity` (in the display locale), `"true"`/`"false"` +for a boolean, the option's `valueJson` for a closed set. The form then encodes +**every cell with the encoder the same member would get at the top level** +(`encodeFieldText`): the same syntax, locale normalisation, precision limit and +declared bounds, so a `Quantity` cell becomes an exact `{num,den,dp}` and an +over-precise one is refused, not rounded. A blank optional cell is omitted from +its row object. The literal is `null` — no value, so the form is not ready — +when the text is not an array of objects, when a cell does not encode, or when +a required member of any row is blank. An empty array is a value: `[]` is +submitted for it. + +Cells are always read in the member's **canonical** unit: a row has no unit +selector of its own. Only a **top-level** collection is handed to a slot; one +nested inside a row, or inside a nested object, stays unrepresentable. A label +inside a row resolves through an explicit `x-i18nKey` or its literal only — the +derived `.` key names top-level members, and no row-level stem +is defined on the C++ side. + +`src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml` pins the contract +against submitted bodies. Replacing the cell encoder with plain string quoting +reddens 3 of its 17 cases. + ### Boolean fields — `type: "boolean"` glaze emits `{"type": "boolean"}` for a `bool` member, and @@ -1362,6 +1408,14 @@ fields" — advice no input can act on — and, being the label the [accessibility slice](#renderer-conformance-kit) mirrors into `Accessible.description`, announces it rather than merely tinting it. +**A slot can supply the missing encoding for a collection of objects.** When a +registered slot claims a top-level `std::vector` member, `unrepresentable` +is `""` for it and the form encodes the rows the slot writes — see +[Collections of objects](#collections-of-objects--a-host-slot-draws-them). The +descriptor therefore depends on the slot registry as well as on the schema; the +`fields` binding reads `SlotRegistry.revision`, so a slot registered after the +form was built is picked up. + **An unrepresentable member the payload may legitimately omit does not block submission.** Optional and left blank, it is simply absent from the body, and that body is one the schema accepts; `unrepresentable` still names it on the @@ -1489,7 +1543,22 @@ forking the renderer: `Loader.onLoaded` — `field` is the resolved, merged def+property descriptor, and `setValue(text)` is the same set-value path (`setFieldValue`) the built-in controls use, so an override participates in the required-gate and - auto-fire without special-casing. `SlotRegistry.revision` is bumped on every + auto-fire without special-casing. + + A slot may also declare any of four **optional** members, each assigned only + when declared (so an existing slot is unaffected): + + | Member | Assigned as | Use | + |---|---|---| + | `fieldText` | a binding to the field's retained text | Seed and track the control's value. A prefill, `resetFields()`, a `setFieldValue` from code and a tab switch that rebuilds the slot all reach it; without it a slot sees only what it wrote itself. | + | `rows` | a binding to the rows of a [collection of objects](#collections-of-objects--a-host-slot-draws-them), as a JS array of `{member: cellText}` (`[]` when none) | Draw the grid. | + | `setRows` | `function (rows)` | Write the rows; equivalent to `setValue(JSON.stringify(rows))`. | + | `form` | the `DynamicForm` itself | Reach `encodeFieldText(field, text, 0)` (does this cell encode?) and the rest of the form's public surface. | + + Both bindings re-evaluate on `rulesRevision`, which `revalidate()` bumps after + every write to the draft. A slot that claims a collection of objects is also + what makes that member representable — see + [Collections of objects](#collections-of-objects--a-host-slot-draws-them). `SlotRegistry.revision` is bumped on every `by*()` call and read inside `resolve()`, for the same reason `I18nCatalog.revision` exists: `_byField`/`_byWidget`/`_byUnit`/`_byType` are plain objects mutated in place, which does not by itself notify a binding @@ -3108,7 +3177,9 @@ depends on the rendering answer yet. So: an action with a nested-aggregate member — cyclic or otherwise — is a document morph generates completely, a form morph draws only down to the -nesting, and a body morph declines to assemble. +nesting, and a body morph declines to assemble — unless the member is a +top-level collection of objects a host slot draws, which the form then encodes +row by row (see [Collections of objects](#collections-of-objects--a-host-slot-draws-them)). Computed fields, `formLayout`/`fieldSpans`, and `formRules` remain **top-level only** regardless of nesting depth: a nested aggregate declaring any of those diff --git a/docs/spec/forms/views.md b/docs/spec/forms/views.md index c63a81899..4c07dad38 100644 --- a/docs/spec/forms/views.md +++ b/docs/spec/forms/views.md @@ -432,10 +432,13 @@ pattern: + name` convention (the default `TextField` a plain integer/string property renders as) and sets its `text`. A `bind` entry targeting a field DynamicForm renders as a `Choice` combo box, a `Timestamp`/date-time - picker, a slider, a multiline text area, a radio group, or a - `SlotRegistry`-overridden control is not currently prefilled — the control - is simply left at its own default, exactly as an unbound field would be - (no crash, no partial state). Every worked example (`SamplesView`) only + picker, a slider, a multiline text area or a radio group is not currently + prefilled — the control is simply left at its own default, exactly as an + unbound field would be (no crash, no partial state). A + `SlotRegistry`-overridden scalar field is prefilled only when its slot + declares the optional `fieldText` member ([forms.md](forms.md#theming--component-override-registry)): + the hidden default `TextField` still takes the text, and `fieldText` carries + the resulting value to the slot. Every worked example (`SamplesView`) only ever binds the integer row key, which always renders as a plain `TextField`, so this gap does not affect the shipped demo. diff --git a/src/qt/forms/qml/CollectionView.qml b/src/qt/forms/qml/CollectionView.qml index d29f3b015..c29738c7d 100644 --- a/src/qt/forms/qml/CollectionView.qml +++ b/src/qt/forms/qml/CollectionView.qml @@ -81,9 +81,10 @@ Frame { // form.setFieldValue for us. This only reaches the default // (`objectName: "field_" + name`) TextField control DynamicForm.qml // draws for a plain scalar field -- a bound field rendered as a Choice/ - // DateTime/Slider/Multiline/radio-group control (or overridden via - // SlotRegistry) is not currently prefillable this way; see - // docs/spec/forms/views.md, "Limitations". + // DateTime/Slider/Multiline/radio-group control is not currently + // prefillable this way. A SlotRegistry override is, when its slot + // declares `fieldText`: the hidden TextField still takes the text and the + // binding carries it on. See docs/spec/forms/views.md, "Limitations". function findControlByObjectName(item, name) { if (!item) return null diff --git a/src/qt/forms/qml/DynamicForm.qml b/src/qt/forms/qml/DynamicForm.qml index 3f43058d5..7aee7d9c9 100644 --- a/src/qt/forms/qml/DynamicForm.qml +++ b/src/qt/forms/qml/DynamicForm.qml @@ -26,7 +26,10 @@ // collection whose items are objects -- is *unrepresentable*: no typed text // encodes to the shape the schema asks for. Such a member is named in its // field descriptor's `unrepresentable` and keeps the form short of `ready` -// (docs/spec/forms/forms.md, "What `ready` claims"). +// (docs/spec/forms/forms.md, "What `ready` claims"). The one exception is a +// collection of objects a host slot claims (SlotRegistry): the slot edits the +// rows as cell texts and this form encodes each cell with the same encoders +// its own controls use -- see "Collections of objects" in forms.md. // // Quantity payloads are assembled as JSON text from the typed digit string, // so they are exact at any magnitude (same contract as the HTML renderer). @@ -509,6 +512,8 @@ Frame { if (hit !== undefined && hit !== null) return hit } + if (derivedKey === undefined) + return literal const hit2 = catalog.lookup(displayLocale, derivedKey) if (hit2 !== undefined && hit2 !== null) return hit2 @@ -516,9 +521,33 @@ Frame { } // Flat field descriptors, in declaration (x-order) order. + // + // Reading the registry's revision makes a slot registered after the first + // evaluation re-describe the member it claims: whether a collection of + // objects is representable depends on it (see describeObject). property var fields: { - const props = schemaData.properties || {} - const required = schemaData.required || [] + if (slotRegistry) + slotRegistry.revision + return describeObject(schemaData, 0) + } + + // Whether a host slot claims the member `name` -- the same resolution the + // field delegate performs, so the two cannot disagree. + function slotClaims(name, xWidget, unitAscii, jsonType) { + return slotRegistry !== null && slotRegistry !== undefined + && slotRegistry.resolve(actionType, name, xWidget, unitAscii, jsonType) !== null + } + + // Field descriptors for one object schema's properties, in x-order order. + // `depth` is 0 for the action itself and 1 for the element of a top-level + // collection of objects (`itemFields` below); element members are + // described once and never recursed into further, so a self-referential + // row type cannot loop. At depth 1 a label resolves through an explicit + // x-i18nKey or the literal only: the derived "." key names + // top-level members. + function describeObject(objectSchema, depth) { + const props = (objectSchema && objectSchema.properties) || {} + const required = (objectSchema && objectSchema.required) || [] return Object.keys(props) .sort(function (a, b) { return opt(props[a]["x-order"], 0) - opt(props[b]["x-order"], 0) }) .map(function (name) { @@ -579,15 +608,28 @@ Frame { const typedControl = dp !== undefined || optionsAction !== undefined || enumOptionRows.length > 0 || p.format === "date-time" || types.indexOf("integer") !== -1 || types.indexOf("boolean") !== -1 + // A collection whose element is an object schema with members + // of its own (glaze's std::vector): the shape a host + // grid slot edits row by row. + const itemSchema = types.indexOf("array") !== -1 ? resolveProp(p.items) : {} + const isObjectArray = jsonTypes(itemSchema).indexOf("object") !== -1 + && itemSchema.properties !== undefined + const jsonType = types.length > 0 ? types[0] : "" + // Only a top-level collection is handed to a slot: its rows + // are stored in the collection's own fieldValues entry, which + // a member one level down does not have. + const claimedBySlot = depth === 0 && isObjectArray + && slotClaims(name, opt(widget, ""), opt(extUnits.unitAscii, ""), jsonType) + const derivedKey = function (slot) { return depth === 0 ? i18nFieldKey(name, slot) : undefined } return { name: name, title: literalTitle, label: resolveText(i18nExplicitFieldKey(i18nOverride, "label"), - i18nFieldKey(name, "label"), literalTitle), + derivedKey("label"), literalTitle), description: resolveText(i18nExplicitFieldKey(i18nOverride, "help"), - i18nFieldKey(name, "help"), literalHelp), + derivedKey("help"), literalHelp), placeholder: resolveText(i18nExplicitFieldKey(i18nOverride, "placeholder"), - i18nFieldKey(name, "placeholder"), literalPlaceholder), + derivedKey("placeholder"), literalPlaceholder), readOnly: opt(raw["x-readonly"], opt(p["x-readonly"], false)), hidden: opt(raw["x-hidden"], opt(p["x-hidden"], false)), unit: unitText, @@ -644,12 +686,26 @@ Frame { // but each entry is encoded as a JSON string, same as an // array of strings, rather than silently misencoding. isArray: types.indexOf("array") !== -1, + // A collection of objects, and -- for a top-level one -- + // its element's member descriptors, in x-order order: the + // columns a grid slot draws (label, unit, decimals, + // readOnly, required, and the kind flags that say how a + // cell's text is encoded). Empty for any other member. + isObjectArray: isObjectArray, + itemFields: (isObjectArray && depth === 0) ? describeObject(itemSchema, depth + 1) : [], + // True when a registered slot draws this collection, which + // is what makes it representable (see `unrepresentable`). + claimedBySlot: claimedBySlot, // Why no control here can collect what the schema asks // for, or "" for every member this renderer represents -- // which is every member of a flat action. A non-empty // reason makes the member unencodable, so the form reports // ready only for a payload that legitimately omits it. - unrepresentable: unrepresentableMemberReason(p, types, typedControl), + // + // A top-level collection of objects a slot claims is the + // exception: the slot collects each row's cell texts and + // encodeObjectArray encodes them, so an encoding exists. + unrepresentable: claimedBySlot ? "" : unrepresentableMemberReason(p, types, typedControl), required: required.indexOf(name) !== -1, // `resolveRef` merges the property node *over* the `$def` // it points at, so these three read a per-field bound @@ -703,7 +759,7 @@ Frame { // SlotRegistry.byKind (see fieldKind). kind: fieldKind(p, types, dp, optionsAction, enumOptionRows.length > 0), unitAscii: opt(extUnits.unitAscii, ""), - jsonType: types.length > 0 ? types[0] : "" + jsonType: jsonType } }) } @@ -1332,6 +1388,62 @@ Frame { // returns "[]" -- a genuinely empty array is still a valid array // literal, distinct from the field itself being unengaged (handled by // fieldJsonLiteral's blank-text check before this is ever called). + // A collection-of-objects field's rows, as the JS array a slot wrote with + // setRows (JSON text in fieldValues), or [] when there are none yet or the + // text is not an array. + function objectArrayRows(text) { + if (text === undefined || text === null || String(text).trim() === "") + return [] + try { + const parsed = JSON.parse(text) + return Array.isArray(parsed) ? parsed : [] + } catch (ignored) { + return [] + } + } + + // Encodes a collection of objects from its rows' cell texts: `text` is a + // JSON array of `{member: cellText}` objects, one per row, where each cell + // text is what the built-in control for that member would hold (a digit + // string for a number or Quantity, "true"/"false" for a boolean, the + // option's `valueJson` for a closed set). Each cell goes through + // encodeFieldText with the element's own member descriptor, so the same + // syntax, locale, precision and bound rules apply as at the top level; a + // blank optional cell is omitted from its row object. Returns null -- no + // literal, so the form is not ready -- when the text is not an array of + // objects, a cell does not encode, or a required member is blank. + function encodeObjectArray(f, text) { + let rows + try { + rows = JSON.parse(text) + } catch (ignored) { + return null + } + if (!Array.isArray(rows)) + return null + const encodedRows = [] + for (let r = 0; r < rows.length; ++r) { + const row = rows[r] + if (row === null || typeof row !== "object" || Array.isArray(row)) + return null + const parts = [] + for (let m = 0; m < f.itemFields.length; ++m) { + const member = f.itemFields[m] + const cell = row[member.name] + const cellText = (cell === undefined || cell === null) ? "" : String(cell) + const literal = encodeFieldText(member, cellText, 0) + if (literal === null) { + if (cellText.trim() !== "" || member.required) + return null + continue + } + parts.push(JSON.stringify(member.name) + ":" + literal) + } + encodedRows.push("{" + parts.join(",") + "}") + } + return "[" + encodedRows.join(",") + "]" + } + function arrayJsonLiteral(text) { const items = text.split(",") .map(function (item) { return item.trim() }) @@ -1346,7 +1458,16 @@ Frame { // revalidate() (the submit body) and optionsRequestBody() (a dependent // Choice's parent values). function fieldJsonLiteral(f) { - const text = (opt(fieldValues[f.name], "")).trim() + return encodeFieldText(f, opt(fieldValues[f.name], ""), opt(fieldUnits[f.name], 0)) + } + + // The encoder behind fieldJsonLiteral, over an explicit text and unit + // selection instead of the form's own draft -- so a collection's cells, + // which have no entry in fieldValues, go through exactly the rules a + // top-level control's text does. `unitIndex` selects from f.unitOptions + // (0 is the canonical unit). + function encodeFieldText(f, rawText, unitIndex) { + const text = String(rawText === undefined || rawText === null ? "" : rawText).trim() if (text === "") return null // A member no control can collect has no literal, whatever was typed: @@ -1355,6 +1476,9 @@ Frame { // makes the form report ready. if (f.unrepresentable !== "") return null + if (f.isObjectArray) { + return encodeObjectArray(f, text) + } if (f.isArray) { return arrayJsonLiteral(text) } @@ -1392,7 +1516,7 @@ Frame { }) if (canonicalText === null || !/^-?\d+(\.\d+)?$/.test(canonicalText)) return null - const unit = f.unitOptions[opt(fieldUnits[f.name], 0)] + const unit = f.unitOptions[unitIndex] // Reject more decimals than the current unit's precision instead // of silently rounding them away. const fracLen = (canonicalText.split(".")[1] || "").length @@ -1400,7 +1524,7 @@ Frame { return null const value = parseFloat(canonicalText) // Bounds are declared against the canonical unit. - if (opt(fieldUnits[f.name], 0) === 0) { + if (unitIndex === 0) { if (f.minimum !== undefined && value < f.minimum) return null if (f.maximum !== undefined && value > f.maximum) @@ -1859,9 +1983,33 @@ Frame { // same set-value path the built-in controls use, so an // override participates in the required-gate and // auto-fire without special-casing. + // + // Optional, each assigned only when the slot declares it: + // `fieldText` (the retained text, kept current -- a prefill, + // a reset or a rebuilt tab reaches the slot through it), + // `rows` / `setRows(rows)` (a collection of objects as a JS + // array of {member: cellText}), and `form` (this form, for + // encodeFieldText and the rest of its public surface). onLoaded: { + const name = fieldColumn.modelData.name item.field = fieldColumn.modelData - item.setValue = function (text) { form.setFieldValue(fieldColumn.modelData.name, text) } + item.setValue = function (text) { form.setFieldValue(name, text) } + // revalidate() bumps rulesRevision after every write to + // fieldValues, a plain object that notifies nothing. + if ("fieldText" in item) + item.fieldText = Qt.binding(function () { + form.rulesRevision + return form.opt(form.fieldValues[name], "") + }) + if ("rows" in item) + item.rows = Qt.binding(function () { + form.rulesRevision + return form.objectArrayRows(form.fieldValues[name]) + }) + if ("setRows" in item) + item.setRows = function (rows) { form.setFieldValue(name, JSON.stringify(rows)) } + if ("form" in item) + item.form = form } } diff --git a/src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml b/src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml new file mode 100644 index 000000000..758f0effb --- /dev/null +++ b/src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// A collection of objects (`std::vector`) drawn by a host slot. +// +// Without a slot the renderer has no encoding for such a member and reports it +// unrepresentable (tst_DynamicFormNestedAggregate.qml pins that, unchanged). +// With one, the slot is handed the element's member descriptors -- the columns +// of a grid -- writes the rows as `{member: cellText}` objects, and the form +// encodes every cell with the encoder the same member would get at the top +// level. Every case below asserts the submitted body, because "the slot wrote +// something" is not the claim; "the payload satisfies the schema" is. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest +import MorphForms + +TestCase { + id: testCase + name: "DynamicFormObjectArraySlot" + visible: true + + // `struct Row { double sieve; Quantity passing; std::string note; + // std::int64_t order; }` held as `std::vector rows`, beside a plain + // `std::vector tags`, the shape schemaJson() emits: the + // row type under `$defs`, reached through `items.$ref`. + property var gradingSchema: ({ + "$defs": { + "double": { type: "number", minimum: -1.7976931348623157e+308, maximum: 1.7976931348623157e+308 }, + "int64_t": { type: "integer", minimum: -9223372036854775808, maximum: 9223372036854775807 }, + "Row": { + type: "object", + properties: { + note: { type: ["string", "null"], "x-order": 2, title: "Note" }, + sieve: { + "$ref": "#/$defs/double", "x-order": 0, title: "Sieve", + ExtUnits: { unitAscii: "mm", unitUnicode: "mm" } + }, + passing: { + type: ["object", "null"], + properties: { num: { type: "integer" }, den: { type: "integer" }, dp: { type: "integer" } }, + ExtUnits: { unitAscii: "pct", unitUnicode: "%" }, + "x-decimalPlaces": 1, "x-order": 1, title: "Passing" + }, + order: { "$ref": "#/$defs/int64_t", "x-order": 3, title: "Order", "x-readonly": true } + }, + required: ["sieve", "passing"] + } + }, + properties: { + rows: { type: "array", items: { "$ref": "#/$defs/Row" }, "x-order": 0, title: "Rows" }, + tags: { type: "array", items: { type: "string" }, "x-order": 1, title: "Tags" } + }, + required: ["rows"] + }) + + // A grid slot: it declares the whole optional contract, and records what + // it was handed. + Component { + id: gridSlot + Item { + objectName: "gridSlot" + property var field + property var setValue + property var rows: [] + property var setRows + property string fieldText + property var form + } + } + + // A slot declaring only the two mandatory members: the optional ones must + // not be assigned (assigning an undeclared property is an error). + Component { + id: minimalSlot + Item { + objectName: "minimalSlot" + property var field + property var setValue + } + } + + Component { + id: registryComponent + SlotRegistry {} + } + + // A scalar slot declaring `fieldText`: the optional member is not specific + // to collections. + property var labelSchema: ({ + properties: { label: { type: "string", "x-order": 0, title: "Label" } }, + required: ["label"] + }) + + Component { + id: textSlot + Item { + objectName: "textSlot" + property var field + property var setValue + property string fieldText + } + } + + Component { + id: labelForm + DynamicForm { actionType: "T_Label"; schema: testCase.labelSchema; controller: null } + } + + Component { + id: gradingForm + DynamicForm { actionType: "T_Grading"; schema: testCase.gradingSchema; controller: null } + } + + function formWithSlot(component) { + const registry = createTemporaryObject(registryComponent, testCase) + registry.byField("T_Grading", "rows", component) + return createTemporaryObject(gradingForm, testCase, { slotRegistry: registry }) + } + + // ── without a slot: unchanged ──────────────────────────────────────────── + + function test_without_a_slot_the_collection_stays_unrepresentable() { + const form = createTemporaryObject(gradingForm, testCase) + const rows = form.fieldByName["rows"] + compare(rows.isObjectArray, true) + compare(rows.claimedBySlot, false) + verify(rows.unrepresentable !== "") + compare(form.ready, false) + } + + function test_a_collection_of_strings_is_not_an_object_array() { + const form = createTemporaryObject(gradingForm, testCase) + const tags = form.fieldByName["tags"] + compare(tags.isObjectArray, false) + compare(tags.itemFields.length, 0) + compare(tags.unrepresentable, "") + } + + // ── the element schema a grid draws its columns from ───────────────────── + + function test_the_slot_receives_the_element_members_in_order() { + const form = formWithSlot(gridSlot) + const slot = findChild(form, "gridSlot") + verify(slot !== null) + compare(slot.field.name, "rows") + compare(slot.field.claimedBySlot, true) + compare(slot.field.unrepresentable, "") + const columns = slot.field.itemFields + compare(columns.length, 4) + compare(columns[0].name, "sieve") + compare(columns[1].name, "passing") + compare(columns[2].name, "note") + compare(columns[3].name, "order") + } + + function test_each_column_carries_its_label_unit_decimals_and_flags() { + const form = formWithSlot(gridSlot) + const columns = findChild(form, "gridSlot").field.itemFields + compare(columns[0].label, "Sieve") + compare(columns[0].unit, "mm") + compare(columns[0].isNumber, true) + compare(columns[0].required, true) + compare(columns[1].unit, "%") + compare(columns[1].isQuantity, true) + compare(columns[1].decimals, 1) + compare(columns[2].required, false) + compare(columns[3].readOnly, true) + compare(columns[3].isInteger, true) + } + + // ── writing the rows ───────────────────────────────────────────────────── + + function test_rows_encode_with_each_members_own_encoder() { + const form = formWithSlot(gridSlot) + const slot = findChild(form, "gridSlot") + slot.setRows([{ sieve: "31.5", passing: "100.0" }, { sieve: "0.063", passing: "4.2", note: "fines" }]) + compare(form.ready, true) + compare(form.previewLine, + '{"rows":[{"sieve":31.5,"passing":{"num":1000,"den":10,"dp":1}},' + + '{"sieve":0.063,"passing":{"num":42,"den":10,"dp":1},"note":"fines"}]}') + } + + function test_an_empty_collection_is_a_valid_value() { + const form = formWithSlot(gridSlot) + findChild(form, "gridSlot").setRows([]) + compare(form.ready, true) + compare(form.previewLine, '{"rows":[]}') + } + + function test_a_blank_required_cell_leaves_the_form_unready() { + const form = formWithSlot(gridSlot) + findChild(form, "gridSlot").setRows([{ sieve: "31.5" }]) + compare(form.ready, false) + compare(form.previewLine, "") + } + + function test_a_cell_that_does_not_encode_leaves_the_form_unready() { + const form = formWithSlot(gridSlot) + const slot = findChild(form, "gridSlot") + slot.setRows([{ sieve: "abc", passing: "1.0" }]) + compare(form.ready, false) + // An over-precise Quantity cell is refused, not rounded, as at the top. + slot.setRows([{ sieve: "1", passing: "1.25" }]) + compare(form.ready, false) + slot.setRows([{ sieve: "1", passing: "1.2" }]) + compare(form.ready, true) + } + + function test_a_value_that_is_not_an_array_of_objects_has_no_literal() { + const form = formWithSlot(gridSlot) + const slot = findChild(form, "gridSlot") + slot.setValue('{"sieve":"1"}') + compare(form.ready, false) + slot.setValue('[1, 2]') + compare(form.ready, false) + slot.setValue('not json') + compare(form.ready, false) + } + + function test_non_string_cells_are_read_as_their_text() { + const form = formWithSlot(gridSlot) + findChild(form, "gridSlot").setRows([{ sieve: 2.5, passing: "3.0", order: 7 }]) + compare(form.ready, true) + compare(form.previewLine, '{"rows":[{"sieve":2.5,"passing":{"num":30,"den":10,"dp":1},"order":7}]}') + } + + // ── reading the rows back ──────────────────────────────────────────────── + + function test_the_slot_reads_back_what_it_wrote() { + const form = formWithSlot(gridSlot) + const slot = findChild(form, "gridSlot") + const written = [{ sieve: "8", passing: "55.0" }] + slot.setRows(written) + compare(slot.rows.length, 1) + compare(slot.rows[0].sieve, "8") + compare(slot.fieldText, JSON.stringify(written)) + verify(slot.form === form) + } + + function test_a_reset_reaches_the_slot() { + const form = formWithSlot(gridSlot) + const slot = findChild(form, "gridSlot") + slot.setRows([{ sieve: "8", passing: "55.0" }]) + form.resetFields() + compare(slot.rows.length, 0) + compare(slot.fieldText, "") + compare(form.ready, false) + } + + function test_a_programmatic_write_reaches_the_slot() { + const form = formWithSlot(gridSlot) + const slot = findChild(form, "gridSlot") + form.setFieldValue("rows", JSON.stringify([{ sieve: "1", passing: "2.0" }, { sieve: "2", passing: "3.0" }])) + compare(slot.rows.length, 2) + compare(slot.rows[1].sieve, "2") + } + + function test_a_prefill_through_the_default_control_reaches_a_scalar_slot() { + const registry = createTemporaryObject(registryComponent, testCase) + registry.byField("T_Label", "label", textSlot) + const form = createTemporaryObject(labelForm, testCase, { slotRegistry: registry }) + const slot = findChild(form, "textSlot") + // What CollectionView's row-open prefill does: write the hidden + // default control's text. + findChild(form, "field_label").text = "Sample 7" + compare(slot.fieldText, "Sample 7") + compare(form.previewLine, '{"label":"Sample 7"}') + } + + function test_a_slot_declaring_only_the_mandatory_contract_still_works() { + const form = formWithSlot(minimalSlot) + const slot = findChild(form, "minimalSlot") + verify(slot !== null) + slot.setValue(JSON.stringify([{ sieve: "1", passing: "2.0" }])) + compare(form.ready, true) + } + + function test_a_slot_registered_after_the_form_is_built_is_picked_up() { + const registry = createTemporaryObject(registryComponent, testCase) + const form = createTemporaryObject(gradingForm, testCase, { slotRegistry: registry }) + verify(form.fieldByName["rows"].unrepresentable !== "") + registry.byField("T_Grading", "rows", gridSlot) + compare(form.fieldByName["rows"].unrepresentable, "") + tryVerify(function () { return findChild(form, "gridSlot") !== null }) + } +} From 34f353c1784d278edaaba825361f469edfdd6874 Mon Sep 17 00:00:00 2001 From: yaraslau Date: Thu, 24 Sep 2026 22:29:11 +0200 Subject: [PATCH 2/5] docs(forms): the object-array suite has 18 cases, not 17 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/spec/forms/forms.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index 49e8d2c78..f5a3f29b7 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -985,7 +985,7 @@ is defined on the C++ side. `src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml` pins the contract against submitted bodies. Replacing the cell encoder with plain string quoting -reddens 3 of its 17 cases. +reddens 3 of its 18 cases. ### Boolean fields — `type: "boolean"` From 1d151f9eb8681062d8b5f8b1c2b2336ce86705ad Mon Sep 17 00:00:00 2001 From: Yaraslau Date: Fri, 25 Sep 2026 09:15:30 +0300 Subject: [PATCH 3/5] forms(qml): prefill(values) / prefillFromJson(text) -- load a stored record into DynamicForm for editing (fixes #814) (#817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every prefill path wrote *control text*: CollectionView sets a `field_` TextField, WizardView calls setFieldValue with text. An editing flow -- open a saved sample, read its section DTO, prefill the form, edit, submit -- had to turn each wire value into the text its control holds by hand: a {num,den,dp} into locale digits at the declared precision, an ISO instant into the display zone, a closed-set value into its valueJson, and every row of a std::vector into cell texts, the inverse of the encoder #809 added. `decodeFieldValue(field, value)` is that inverse, kind by kind, on exact digits (a Quantity's num/den are divided as digit strings, an int64 id from JsonExact.parse stays exact). `prefill(values)` replaces the whole draft from a payload object, resets unit selectors to the canonical unit, bumps `prefillRevision` so every drawn control re-seeds (a fetched Choice also re-selects when its options arrive -- before this it showed "— select —" for a retained value), re-fetches dependent Choices, and revalidates *inside* the programmaticEdit window. That last point is measured, not assumed: the first version reused withoutAutoSubmit, whose final revalidate runs after the suppression is lifted, and the round-trip case caught the ready prefilled form submitting itself. `prefillFromJson(text)` parses with JsonExact. tst_DynamicFormPrefill.qml (11 cases) asserts the round trip against previewLine for every member kind, the drawn controls and slots holding the values, de_DE and a UTC+2 display zone, a later edit, replacement semantics, a fetched Choice with an id past 2^53, and no submission. Removing the re-seed reddens 5 cases; a wrong Quantity decoder reddens 8. forms_qml_logic: 348 passed on MSVC 14.51 / Qt 6.11.1. Stacked on #809 (the rows / fieldText slot contract and itemFields it decodes into). Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 11 + docs/spec/forms/forms.md | 40 +++ src/qt/forms/qml/DynamicForm.qml | 229 +++++++++++++++ src/qt/forms/tests/tst_DynamicFormPrefill.qml | 276 ++++++++++++++++++ 4 files changed, 556 insertions(+) create mode 100644 src/qt/forms/tests/tst_DynamicFormPrefill.qml diff --git a/CHANGELOG.md b/CHANGELOG.md index 9efe05638..fbc9341b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,17 @@ API surface). `invalid` states (`DynamicForm.fieldInvalid(name)`); the status line's text is now `DynamicForm.statusText`. With nothing registered, every form renders as before. See `docs/spec/forms/forms.md`, "Chrome slots". +- **`DynamicForm.prefill(values)` / `prefillFromJson(text)` — load a stored + record for editing.** Every prefill path wrote *control text*, so an editing + flow had to turn each wire value into the text its control holds by hand — + a `{num,den,dp}` into locale digits, an ISO instant into the display zone, a + row of a `std::vector` into cell texts. `decodeFieldValue(field, value)` + is now the inverse of the encoders, and `prefill` replaces the draft from a + payload, re-seeds every control and slot (`fieldText`, `rows`), re-selects + fetched `Choice`s and never submits. Prefilling and editing nothing + assembles the same payload. See `docs/spec/forms/forms.md`, "Prefill" + (fixes #814). + - **A host slot can draw a `std::vector` member, and the form encodes it.** A collection of objects had no built-in control and was reported unrepresentable, so a form carrying one could not be submitted whatever the diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index f5a3f29b7..84d7ff92f 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -1362,6 +1362,46 @@ which is what `maximum: Infinity` already meant, and matches JSON Schema giving a null numeric keyword no meaning. Without that, a null bound would read as the bound `0` and reject every positive value. +### Prefill — loading a stored payload for editing + +An editing flow opens a saved record, reads its DTO and wants the form to show +it. `DynamicForm.prefill(values)` takes the payload as an object keyed by wire +name (a parsed action or section DTO); `prefillFromJson(text)` takes its JSON +and parses it with `JsonExact`, so an id past 2^53 arrives digit for digit. +Either replaces the whole draft — a member absent from the payload starts +blank, as after `resetFields()` — and returns `false`, changing nothing, for +input that is not an object. + +Each value becomes the text the built-in control for that member would hold, +through `decodeFieldValue(field, value)`, the inverse of `encodeFieldText`: + +| Member | Wire value | Draft text | +|---|---|---| +| `Quantity` | `{num,den,dp}` | exact digits at the field's canonical `x-decimalPlaces`, rounded half-up, in the display locale (`2450.50`, `2450,50` in `de_DE`); the unit selector returns to the canonical unit | +| plain number | JSON number | never exponent form; padded to `x-displayDecimals` when declared, never rounded to it | +| integer | JSON integer | its exact digits | +| `Timestamp` | ISO-8601 (zone designator optional, read as UTC when absent) | wall clock in `displayOffsetMinutes` | +| `boolean` | `true`/`false` | `"true"`/`"false"` | +| closed set / `Choice` | the value | its `valueJson` | +| `std::vector` | array | entries joined by `", "` | +| `std::vector` | array of objects | the rows as `{member: cellText}`, each cell decoded by the row member's own descriptor | +| string | string | itself | + +A value whose shape does not match its field decodes to `""` (blank). The +round trip is the contract: prefilling a form from a payload and editing +nothing assembles the same payload, in the canonical spelling the encoders +produce. Every drawn control re-seeds from the new draft (`prefillRevision`), +a fetched `Choice` re-selects its row whenever its options arrive, dependent +`Choice`s are re-fetched for their prefilled parents, and slots see the values +through `fieldText` / `rows`. + +**A prefill never submits**, in auto-submit mode included: the final +revalidation runs inside the `programmaticEdit` window, so a ready prefilled +form waits for the user. `src/qt/forms/tests/tst_DynamicFormPrefill.qml` pins +the round trip for every member kind, locale and zone, slots, the fetched +`Choice`, and the no-submit rule; removing the control re-seed reddens 5 of its +11 cases and a wrong `Quantity` decoder 8. + ### What `ready` claims `DynamicForm.ready` is a claim about the **payload**: `true` only when the body diff --git a/src/qt/forms/qml/DynamicForm.qml b/src/qt/forms/qml/DynamicForm.qml index 7aee7d9c9..80257c786 100644 --- a/src/qt/forms/qml/DynamicForm.qml +++ b/src/qt/forms/qml/DynamicForm.qml @@ -165,6 +165,10 @@ Frame { // counter, not a flag, so nested writes (a reset that itself triggers // refreshDependents) cannot re-enable submission early. property int programmaticEdit: 0 + + // Bumped by prefill(): every drawn control re-reads its value from + // fieldValues, exactly as it does when a tab switch recreates it. + property int prefillRevision: 0 property string previewLine: "" property string resultText: "" property bool resultOk: true @@ -1756,6 +1760,174 @@ Frame { }) } + // --- prefill: a stored payload back into the draft --------------------- + + // The draft text a built-in control would hold for the wire value `value` + // of field `f` -- the inverse of encodeFieldText, so that + // encodeFieldText(f, decodeFieldValue(f, v), 0) re-encodes `v`. `value` is + // parsed JSON, ideally from JsonExact.parse so an integer past 2^53 is + // still exact. Numbers come out in the display locale (decimal separator + // and digits, no grouping) and a Timestamp in the display zone, because + // that is what the form's own entry path reads. Returns "" for an absent + // or null value, and for one whose shape does not match the field's. + function decodeFieldValue(f, value) { + if (value === undefined || value === null) + return "" + if (f.isObjectArray) { + if (!Array.isArray(value)) + return "" + const rows = [] + for (let r = 0; r < value.length; ++r) { + const row = value[r] + const cells = {} + if (row !== null && typeof row === "object" && !JsonExact.isExact(row)) { + for (let m = 0; m < f.itemFields.length; ++m) { + const member = f.itemFields[m] + const cell = decodeFieldValue(member, row[member.name]) + if (cell !== "") + cells[member.name] = cell + } + } + rows.push(cells) + } + return JSON.stringify(rows) + } + if (f.isArray) + return Array.isArray(value) ? value.map(function (item) { return JsonExact.text(item) }).join(", ") : "" + if (f.isEnum || f.isChoice) + return JsonExact.literal(value) + if (f.isDateTime) + return typeof value === "string" ? utcIsoToZoned(value, displayOffsetMinutes) : "" + if (f.isQuantity) + return quantityDraftText(value, f.canonDp) + if (f.isBoolean) + return value === true ? "true" : (value === false ? "false" : "") + if (f.isInteger) + return (JsonExact.isExact(value) || typeof value === "number") ? JsonExact.text(value) : "" + if (f.isNumber) + return numberDraftText(value, f.displayDecimals) + return typeof value === "string" ? value : "" + } + + // Canonical decimal text (-?\d+(\.\d+)?) in the display locale, as typed. + function localeDraftNumber(canonical) { + return formatCanonicalNumber(canonical, { + decimalSeparator: qtLocale.decimalPoint, + groupSeparator: "", + negativeSign: qtLocale.negativeSign, + zeroDigit: qtLocale.zeroDigit + }) + } + + // A {num,den,dp} Quantity node as draft text at `dp` fraction digits + // (the field's canonical precision), rounded half-up on exact digits. + function quantityDraftText(node, dp) { + if (node === null || typeof node !== "object" || node.num === undefined || node.den === undefined) + return "" + const numText = JsonExact.text(node.num) + const den = Number(JsonExact.text(node.den)) + if (!/^-?\d+$/.test(numText) || !(den > 0) || den > 1e14 || Math.floor(den) !== den) + return "" + const neg = numText.startsWith("-") + const digits = divRoundDigits((neg ? numText.slice(1) : numText) + "0".repeat(dp), den) + let canonical = digits + if (dp > 0) { + const padded = digits.padStart(dp + 1, "0") + canonical = padded.slice(0, -dp) + "." + padded.slice(-dp) + } + const isZero = /^[0.]*$/.test(canonical) + return localeDraftNumber((neg && !isZero ? "-" : "") + canonical) + } + + // A plain JSON number as draft text: never in exponent form, padded to + // a declared display precision, never rounded to it -- a stored value + // finer than that stays visible, and the entry gate then says so. + function numberDraftText(value, displayDecimals) { + if (JsonExact.isExact(value)) + return localeDraftNumber(JsonExact.text(value)) + if (typeof value !== "number" || !isFinite(value)) + return "" + let canonical = String(value) + if (/e/i.test(canonical)) + canonical = value.toFixed(20).replace(/\.?0+$/, "") + if (displayDecimals !== undefined) { + const fraction = canonical.split(".")[1] || "" + if (fraction.length < displayDecimals) + canonical = (fraction === "" ? canonical + (displayDecimals > 0 ? "." : "") : canonical) + + "0".repeat(displayDecimals - fraction.length) + } + return localeDraftNumber(canonical) + } + + // An ISO-8601 instant as the display zone's wall clock + // ("YYYY-MM-DDTHH:MM:SS"), the inverse of zonedToUtcIso. Text with no zone + // designator is read as UTC, which is what a Timestamp serialises to. + function utcIsoToZoned(text, offsetMinutes) { + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/.exec(text) + if (!m) + return "" + let zoneMinutes = 0 + if (m[7] !== undefined && m[7] !== "Z") { + const zone = m[7].replace(":", "") + zoneMinutes = (zone.charAt(0) === "-" ? -1 : 1) + * (parseInt(zone.slice(1, 3)) * 60 + parseInt(zone.slice(3, 5))) + } + const utcMillis = Date.UTC(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3]), parseInt(m[4]), + parseInt(m[5]), m[6] === undefined ? 0 : parseInt(m[6])) + - zoneMinutes * 60000 + const d = new Date(utcMillis + offsetMinutes * 60000) + const pad = (v, w) => String(v).padStart(w, "0") + return pad(d.getUTCFullYear(), 4) + "-" + pad(d.getUTCMonth() + 1, 2) + "-" + pad(d.getUTCDate(), 2) + + "T" + pad(d.getUTCHours(), 2) + ":" + pad(d.getUTCMinutes(), 2) + ":" + pad(d.getUTCSeconds(), 2) + } + + // Loads a stored payload -- an object keyed by wire name, e.g. a parsed + // action or section DTO -- into the form for editing. Replaces the whole + // draft (a member absent from `values` starts blank, as after + // resetFields), resets every unit selector to the canonical unit, re-seeds + // every drawn control and every slot, re-fetches dependent Choices, and + // never submits: prefilling is not a user action. Returns false, changing + // nothing, when `values` is not an object. + function prefill(values) { + if (values === null || typeof values !== "object" || Array.isArray(values) || JsonExact.isExact(values)) + return false + // Not withoutAutoSubmit: that revalidates *after* lifting the + // suppression, and a prefilled form is usually ready, so the final + // pass would submit the record the user only opened. + form.programmaticEdit++ + try { + const draft = {} + for (let i = 0; i < form.fields.length; ++i) { + const f = form.fields[i] + const text = form.decodeFieldValue(f, values[f.name]) + if (text !== "") + draft[f.name] = text + } + form.fieldValues = draft + form.fieldUnits = ({}) + form.prefillRevision++ + for (const parentName in form.dependents) + form.refreshDependents(parentName) + form.revalidate() + } finally { + form.programmaticEdit-- + } + return true + } + + // prefill() over JSON text, parsed exactly (JsonExact), so an id past + // 2^53 reaches the form digit for digit. Returns false for text that does + // not parse to an object. + function prefillFromJson(jsonText) { + let parsed + try { + parsed = JsonExact.parse(String(jsonText)) + } catch (ignored) { + return false + } + return form.prefill(parsed) + } + // The JSON body to send a Choice field's options action: {parentName: // value, ...} built from the current values of its declared parents // (x-optionsDependsOn). Returns null when any parent is not yet engaged @@ -1957,6 +2129,59 @@ Frame { }) } + // prefill() rewrote fieldValues: re-read it into every control of + // this field, the way each already does when it is created. A + // fetched Choice (combo or radio group) also re-selects whenever + // its options arrive, since the prefilled value may predate them. + function reseedFromDraft() { + form.withoutAutoSubmit(function () { + const name = fieldColumn.modelData.name + const retained = form.opt(form.fieldValues[name], "") + entry.text = retained + arrayEntry.text = retained + notesArea.text = retained + if (fieldColumn.modelData.isDateTime) + dateTimeEntry.text = retained + if (fieldColumn.modelData.isBoolean) { + // The rule the CheckBox's creation applies: a required + // box always shows a state, so it holds one. + if (retained === "" && fieldColumn.modelData.required) + form.setFieldValue(name, "false") + boolEntry.checked = retained === "true" + } + if (fieldColumn.modelData.isSlider && retained !== "") + levelSlider.value = Number(retained) + unitSelector.currentIndex = 0 + fieldColumn.reselectOption() + }) + } + + function reselectOption() { + const data = fieldColumn.modelData + if (!data.isEnum && !data.isChoice) + return + const retained = form.opt(form.fieldValues[data.name], "") + const rows = data.isEnum ? data.enumOptions : (form.fieldOptions[data.name] || []) + let index = -1 + for (let i = 0; i < rows.length; ++i) { + if (rows[i].valueJson === retained) { + index = i + break + } + } + choiceEntry.currentIndex = index + radioGroup.checkedIndex = index + } + + Connections { + target: form + function onPrefillRevisionChanged() { fieldColumn.reseedFromDraft() } + function onOptionsRevisionChanged() { + if (fieldColumn.modelData.isChoice) + fieldColumn.reselectOption() + } + } + RowLayout { id: controlsRow Layout.fillWidth: true @@ -2114,6 +2339,8 @@ Frame { } DateTimePicker { + id: dateTimeEntry + objectName: "datetime_" + fieldColumn.modelData.name visible: overrideLoader.sourceComponent === null && fieldColumn.modelData.isDateTime enabled: !fieldColumn.modelData.readOnly Layout.fillWidth: true @@ -2304,6 +2531,8 @@ Frame { // Unit selector when the unit system declares convertible // alternatives: switching recalculates the entry exactly. ComboBox { + id: unitSelector + objectName: "unit_" + fieldColumn.modelData.name visible: overrideLoader.sourceComponent === null && fieldColumn.modelData.isQuantity && fieldColumn.modelData.unitOptions.length > 1 enabled: !fieldColumn.modelData.readOnly diff --git a/src/qt/forms/tests/tst_DynamicFormPrefill.qml b/src/qt/forms/tests/tst_DynamicFormPrefill.qml new file mode 100644 index 000000000..b7c6974f4 --- /dev/null +++ b/src/qt/forms/tests/tst_DynamicFormPrefill.qml @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Loading a stored payload into DynamicForm for editing: prefill(values) / +// prefillFromJson(text), the inverse of the form's own encoders. +// +// The claim is a round trip: prefilling a form from a payload and changing +// nothing must assemble the same payload again. Every case below therefore +// asserts previewLine against the JSON it was fed (in the canonical spelling +// the encoders produce), plus the half a user sees -- the drawn control, or a +// slot's fieldText/rows, holding the value -- and that prefilling never +// submits. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtTest +import MorphForms + +TestCase { + id: testCase + name: "DynamicFormPrefill" + visible: true + width: 600 + height: 900 + + QtObject { + id: recordingController + property int submissions: 0 + signal replyReceived(string actionType, bool ok, string payload) + signal optionsReceived(string optionsAction, bool ok, string payload) + function submitIfValid(actionType, bodyJson) { submissions++ } + function fetchOptions(optionsAction, bodyJson) { + optionsReceived(optionsAction, true, '[{"id":9007199254740993,"name":"Big"},{"id":2,"name":"Two"}]') + } + } + + // One member of every kind the section DTOs use, in schemaJson() shape. + property var sampleSchema: ({ + "$defs": { + "double": { type: "number" }, + "int64_t": { type: "integer" }, + "Row": { + type: "object", + properties: { + sieve: { "$ref": "#/$defs/double", "x-order": 0 }, + passing: { + type: ["object", "null"], + properties: { num: { type: "integer" }, den: { type: "integer" }, dp: { type: "integer" } }, + "x-decimalPlaces": 1, "x-order": 1 + } + }, + required: ["sieve", "passing"] + } + }, + properties: { + id: { "$ref": "#/$defs/int64_t", "x-order": 0 }, + density: { + type: ["object", "null"], + properties: { num: { type: "integer" }, den: { type: "integer" }, dp: { type: "integer" } }, + ExtUnits: { unitAscii: "kg_per_m3", unitUnicode: "kg/m³" }, "x-decimalPlaces": 2, "x-order": 1, + "x-unitAlternatives": [{ id: "g_per_cm3", display: "g/cm³", decimals: 5, num: 1000, den: 1 }] + }, + temperature: { "$ref": "#/$defs/double", "x-order": 2 }, + takenAt: { type: "string", format: "date-time", "x-order": 3 }, + done: { type: "boolean", "x-order": 4 }, + role: { type: "string", oneOf: [{ title: "A", const: "A" }, { title: "B", const: "B" }], "x-order": 5 }, + note: { type: ["string", "null"], "x-order": 6 }, + tags: { type: "array", items: { type: "string" }, "x-order": 7 }, + rows: { type: "array", items: { "$ref": "#/$defs/Row" }, "x-order": 8 } + }, + required: ["id", "density", "temperature", "takenAt", "done", "role", "rows"] + }) + + readonly property string storedPayload: + '{"id":9007199254740993,"density":{"num":245050,"den":100,"dp":2},"temperature":21.5,' + + '"takenAt":"2026-07-20T09:00:00Z","done":true,"role":"B","note":"retest","tags":["a","b"],' + + '"rows":[{"sieve":31.5,"passing":{"num":1000,"den":10,"dp":1}},{"sieve":0.1,"passing":{"num":42,"den":10,"dp":1}}]}' + + // The payload in the spelling the encoders produce -- here, the same one. + readonly property string canonicalPayload: + '{"id":9007199254740993,"density":{"num":245050,"den":100,"dp":2},"temperature":21.5,' + + '"takenAt":"2026-07-20T09:00:00Z","done":true,"role":"B","note":"retest","tags":["a","b"],' + + '"rows":[{"sieve":31.5,"passing":{"num":1000,"den":10,"dp":1}},{"sieve":0.1,"passing":{"num":42,"den":10,"dp":1}}]}' + + property var choiceSchema: ({ + properties: { sample: { type: "integer", "x-optionsAction": "ListSamples", "x-order": 0 } }, + required: ["sample"] + }) + + Component { + id: gridSlot + Item { + objectName: "gridSlot" + property var field + property var setValue + property var rows: [] + property string fieldText + } + } + + Component { + id: noteSlot + Item { + objectName: "noteSlot" + property var field + property var setValue + property string fieldText + } + } + + Component { + id: registryComponent + SlotRegistry {} + } + + Component { + id: sampleForm + DynamicForm { actionType: "T_Sample"; schema: testCase.sampleSchema; controller: recordingController } + } + + Component { + id: choiceForm + DynamicForm { actionType: "T_Choice"; schema: testCase.choiceSchema; controller: recordingController } + } + + function makeSampleForm(extra) { + const registry = createTemporaryObject(registryComponent, testCase) + registry.byField("T_Sample", "rows", gridSlot) + registry.byField("T_Sample", "note", noteSlot) + const props = { slotRegistry: registry } + for (const key in (extra || {})) + props[key] = extra[key] + return createTemporaryObject(sampleForm, testCase, props) + } + + // ── the round trip ─────────────────────────────────────────────────────── + + function test_a_stored_payload_round_trips_through_the_form() { + recordingController.submissions = 0 + const form = makeSampleForm() + verify(form.prefillFromJson(testCase.storedPayload)) + compare(form.ready, true) + compare(form.previewLine, testCase.canonicalPayload) + // A ready form after a prefill still did not submit. + compare(recordingController.submissions, 0) + } + + function test_the_drawn_controls_show_the_prefilled_values() { + const form = makeSampleForm() + form.prefillFromJson(testCase.storedPayload) + compare(findChild(form, "field_id").text, "9007199254740993") + compare(findChild(form, "field_density").text, "2450.50") + compare(findChild(form, "field_temperature").text, "21.5") + compare(findChild(form, "datetime_takenAt").text, "2026-07-20T09:00:00") + compare(findChild(form, "field_done").checked, true) + compare(findChild(form, "field_role").currentText, "B") + compare(findChild(form, "field_tags").text, "a, b") + } + + function test_slots_receive_the_prefilled_values() { + const form = makeSampleForm() + form.prefillFromJson(testCase.storedPayload) + compare(findChild(form, "noteSlot").fieldText, "retest") + const rows = findChild(form, "gridSlot").rows + compare(rows.length, 2) + compare(rows[0].sieve, "31.5") + compare(rows[0].passing, "100.0") + compare(rows[1].sieve, "0.1") + compare(rows[1].passing, "4.2") + } + + function test_an_edit_after_prefill_is_submitted_as_edited() { + const form = makeSampleForm() + form.prefillFromJson(testCase.storedPayload) + findChild(form, "field_temperature").text = "22.00" + verify(form.previewLine.indexOf('"temperature":22.00') !== -1) + verify(form.previewLine.indexOf('"id":9007199254740993') !== -1) + } + + // ── locale and zone ────────────────────────────────────────────────────── + + function test_numbers_are_prefilled_in_the_display_locale() { + const form = makeSampleForm({ displayLocale: "de_DE" }) + form.prefillFromJson(testCase.storedPayload) + compare(findChild(form, "field_density").text, "2450,50") + compare(findChild(form, "field_temperature").text, "21,5") + compare(form.previewLine, testCase.canonicalPayload) + } + + function test_a_timestamp_is_prefilled_in_the_display_zone() { + const form = makeSampleForm({ displayOffsetMinutes: 120 }) + form.prefillFromJson(testCase.storedPayload) + compare(findChild(form, "datetime_takenAt").text, "2026-07-20T11:00:00") + verify(form.previewLine.indexOf('"takenAt":"2026-07-20T09:00:00Z"') !== -1) + } + + // ── what a prefill replaces ────────────────────────────────────────────── + + function test_a_member_absent_from_the_payload_starts_blank() { + const form = makeSampleForm() + form.prefillFromJson(testCase.storedPayload) + form.prefillFromJson('{"id":5}') + compare(findChild(form, "field_temperature").text, "") + compare(findChild(form, "gridSlot").rows.length, 0) + compare(findChild(form, "noteSlot").fieldText, "") + compare(form.ready, false) + } + + function test_the_unit_selector_returns_to_the_canonical_unit() { + const form = makeSampleForm() + const selector = findChild(form, "unit_density") + selector.currentIndex = 1 + selector.activated(1) + form.prefillFromJson(testCase.storedPayload) + compare(selector.currentIndex, 0) + compare(findChild(form, "field_density").text, "2450.50") + verify(form.previewLine.indexOf('"density":{"num":245050,"den":100,"dp":2}') !== -1) + } + + function test_text_that_is_not_an_object_changes_nothing() { + const form = makeSampleForm() + form.prefillFromJson(testCase.storedPayload) + const before = form.previewLine + verify(!form.prefillFromJson("not json")) + verify(!form.prefillFromJson("[1,2]")) + compare(form.previewLine, before) + } + + // ── a fetched Choice ───────────────────────────────────────────────────── + + function test_a_fetched_choice_is_selected_once_its_options_arrive() { + const form = createTemporaryObject(choiceForm, testCase) + form.prefillFromJson('{"sample":9007199254740993}') + compare(form.previewLine, '{"sample":9007199254740993}') + // Options arrived at construction; the prefilled id selects its row. + const combos = [] + function collect(item) { + if (!item) + return + if (item instanceof ComboBox && item.visible) + combos.push(item) + for (let i = 0; i < item.children.length; ++i) + collect(item.children[i]) + } + collect(form) + compare(combos.length, 1) + compare(combos[0].currentText, "Big") + // A later refetch keeps the selection. + recordingController.fetchOptions("ListSamples", "{}") + compare(combos[0].currentText, "Big") + } + + // ── the decoder on its own ─────────────────────────────────────────────── + + function test_decode_is_the_inverse_of_encode_per_kind() { + const form = makeSampleForm() + const density = form.fieldByName["density"] + compare(form.decodeFieldValue(density, { num: 5, den: 4, dp: 2 }), "1.25") + compare(form.decodeFieldValue(density, { num: -1, den: 3, dp: 2 }), "-0.33") + compare(form.decodeFieldValue(density, "oops"), "") + const temperature = form.fieldByName["temperature"] + compare(form.decodeFieldValue(temperature, 1e-7), "0.0000001") + compare(form.decodeFieldValue(temperature, 3), "3") + // A declared display precision (FieldMeta::decimals' x-displayDecimals) + // pads, and never rounds a finer stored value away. + const twoPlaces = { isNumber: true, displayDecimals: 2 } + compare(form.decodeFieldValue(twoPlaces, 3), "3.00") + compare(form.decodeFieldValue(twoPlaces, 21.5), "21.50") + compare(form.decodeFieldValue(twoPlaces, 1.23456), "1.23456") + compare(form.decodeFieldValue(form.fieldByName["done"], false), "false") + compare(form.decodeFieldValue(form.fieldByName["role"], "A"), '"A"') + compare(form.decodeFieldValue(form.fieldByName["takenAt"], "2026-01-01T00:30:00+01:00"), + "2025-12-31T23:30:00") + } +} From ea3a6fb7c8b73b05e6bfd3c149e5ff07f9f68e37 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Fri, 25 Sep 2026 08:56:17 +0200 Subject: [PATCH 4/5] forms(qml): a slot registered by kind claims an object-array collection too slotClaims() is the delegate's own slot resolution, but it passed five arguments while the delegate passes the kind as well, so a grid registered with SlotRegistry.byKind("objectArray", ...) was drawn while the form kept the collection unrepresentable and never encoded its rows. The kind is now computed once per descriptor and handed to both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y4eif7wQNNhSkHUKYqq5Xq --- docs/spec/forms/forms.md | 2 +- src/qt/forms/qml/DynamicForm.qml | 9 +++++---- .../forms/tests/tst_DynamicFormObjectArraySlot.qml | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index 84d7ff92f..f4622275e 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -985,7 +985,7 @@ is defined on the C++ side. `src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml` pins the contract against submitted bodies. Replacing the cell encoder with plain string quoting -reddens 3 of its 18 cases. +reddens 3 of its 19 cases. ### Boolean fields — `type: "boolean"` diff --git a/src/qt/forms/qml/DynamicForm.qml b/src/qt/forms/qml/DynamicForm.qml index 80257c786..8627c0a6c 100644 --- a/src/qt/forms/qml/DynamicForm.qml +++ b/src/qt/forms/qml/DynamicForm.qml @@ -537,9 +537,9 @@ Frame { // Whether a host slot claims the member `name` -- the same resolution the // field delegate performs, so the two cannot disagree. - function slotClaims(name, xWidget, unitAscii, jsonType) { + function slotClaims(name, xWidget, unitAscii, jsonType, kind) { return slotRegistry !== null && slotRegistry !== undefined - && slotRegistry.resolve(actionType, name, xWidget, unitAscii, jsonType) !== null + && slotRegistry.resolve(actionType, name, xWidget, unitAscii, jsonType, kind) !== null } // Field descriptors for one object schema's properties, in x-order order. @@ -619,11 +619,12 @@ Frame { const isObjectArray = jsonTypes(itemSchema).indexOf("object") !== -1 && itemSchema.properties !== undefined const jsonType = types.length > 0 ? types[0] : "" + const kind = fieldKind(p, types, dp, optionsAction, enumOptionRows.length > 0) // Only a top-level collection is handed to a slot: its rows // are stored in the collection's own fieldValues entry, which // a member one level down does not have. const claimedBySlot = depth === 0 && isObjectArray - && slotClaims(name, opt(widget, ""), opt(extUnits.unitAscii, ""), jsonType) + && slotClaims(name, opt(widget, ""), opt(extUnits.unitAscii, ""), jsonType, kind) const derivedKey = function (slot) { return depth === 0 ? i18nFieldKey(name, slot) : undefined } return { name: name, @@ -761,7 +762,7 @@ Frame { xWidget: opt(widget, ""), // The control this renderer would draw, named for // SlotRegistry.byKind (see fieldKind). - kind: fieldKind(p, types, dp, optionsAction, enumOptionRows.length > 0), + kind: kind, unitAscii: opt(extUnits.unitAscii, ""), jsonType: jsonType } diff --git a/src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml b/src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml index 758f0effb..ab085601f 100644 --- a/src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml +++ b/src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml @@ -138,6 +138,20 @@ TestCase { compare(tags.unrepresentable, "") } + // A slot registered for the kind rather than the member claims the + // collection too: the claim is the delegate's own resolution, kind tier + // included, so a drawn grid is never left unencoded. + function test_a_slot_registered_by_kind_claims_the_collection() { + const registry = createTemporaryObject(registryComponent, testCase) + registry.byKind("objectArray", gridSlot) + const form = createTemporaryObject(gradingForm, testCase, { slotRegistry: registry }) + const slot = findChild(form, "gridSlot") + verify(slot !== null) + compare(slot.field.kind, "objectArray") + compare(slot.field.claimedBySlot, true) + compare(slot.field.unrepresentable, "") + } + // ── the element schema a grid draws its columns from ───────────────────── function test_the_slot_receives_the_element_members_in_order() { From 968596419c1424ab36963b3b58fef7001339f6a3 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Fri, 25 Sep 2026 10:23:13 +0200 Subject: [PATCH 5/5] forms(qml): prefill() suppresses auto-submit through withoutAutoSubmit The helper now revalidates inside its suppression window, so the hand-rolled copy in prefill() and its comment explaining why it avoided the helper no longer describe anything. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y4eif7wQNNhSkHUKYqq5Xq --- src/qt/forms/qml/DynamicForm.qml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/qt/forms/qml/DynamicForm.qml b/src/qt/forms/qml/DynamicForm.qml index 8627c0a6c..54bcbf6ba 100644 --- a/src/qt/forms/qml/DynamicForm.qml +++ b/src/qt/forms/qml/DynamicForm.qml @@ -1892,11 +1892,7 @@ Frame { function prefill(values) { if (values === null || typeof values !== "object" || Array.isArray(values) || JsonExact.isExact(values)) return false - // Not withoutAutoSubmit: that revalidates *after* lifting the - // suppression, and a prefilled form is usually ready, so the final - // pass would submit the record the user only opened. - form.programmaticEdit++ - try { + form.withoutAutoSubmit(function () { const draft = {} for (let i = 0; i < form.fields.length; ++i) { const f = form.fields[i] @@ -1909,10 +1905,7 @@ Frame { form.prefillRevision++ for (const parentName in form.dependents) form.refreshDependents(parentName) - form.revalidate() - } finally { - form.programmaticEdit-- - } + }) return true }