diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a328e207..fbc9341b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,31 @@ 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 + 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..f4622275e 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 19 cases. + ### Boolean fields — `type: "boolean"` glaze emits `{"type": "boolean"}` for a `bool` member, and @@ -1316,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 @@ -1362,6 +1448,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 +1583,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 +3217,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..54bcbf6ba 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). @@ -162,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 @@ -509,6 +516,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 +525,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, kind) { + return slotRegistry !== null && slotRegistry !== undefined + && slotRegistry.resolve(actionType, name, xWidget, unitAscii, jsonType, kind) !== 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 +612,29 @@ 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] : "" + 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, kind) + 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 +691,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 @@ -701,9 +762,9 @@ 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: types.length > 0 ? types[0] : "" + jsonType: jsonType } }) } @@ -1332,6 +1393,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 +1463,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 +1481,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 +1521,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 +1529,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) @@ -1632,6 +1761,167 @@ 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 + form.withoutAutoSubmit(function () { + 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) + }) + 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 @@ -1833,6 +2123,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 @@ -1859,9 +2202,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 } } @@ -1966,6 +2333,8 @@ Frame { } DateTimePicker { + id: dateTimeEntry + objectName: "datetime_" + fieldColumn.modelData.name visible: overrideLoader.sourceComponent === null && fieldColumn.modelData.isDateTime enabled: !fieldColumn.modelData.readOnly Layout.fillWidth: true @@ -2156,6 +2525,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_DynamicFormObjectArraySlot.qml b/src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml new file mode 100644 index 000000000..ab085601f --- /dev/null +++ b/src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml @@ -0,0 +1,302 @@ +// 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, "") + } + + // 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() { + 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 }) + } +} 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") + } +}