From 9e4d598a0d875883d2fa9e955154a9df18432089 Mon Sep 17 00:00:00 2001 From: yaraslau Date: Thu, 24 Sep 2026 22:33:41 +0200 Subject: [PATCH 1/2] forms(qml): chrome slots -- a host replaces DynamicForm's labels, containers and buttons, not only its controls SlotRegistry lets a host swap a field's *control*, but everything around the controls stayed built-in: the bold caption and red `*`, the help line, the section heading and accordion toggle, the TabBar, the action-type heading, the status line, the Submit button, the monospace JSON preview and the reply line. A host whose look comes from one UI kit therefore could not get a form that looked like the rest of the app, whatever slots it registered. `SlotRegistry.byChrome(role, component)` / `resolveChrome(role)` register one Component per role -- fieldLabel, fieldHelp, section, accordion (falls back to section), tabset, header, status, submitButton, preview, result -- and DynamicForm loads it in place of the built-in, which is hidden rather than drawn beside it. Each chrome item is handed only the members it declares (`bindChrome`), as bindings where the value changes: a label gets `required` (including requiredWhen) and `invalid` (typed text that does not encode, via the new `fieldInvalid(name)`); the status line's text moved into `DynamicForm.statusText` so the built-in and a chrome read one source. Container chrome (section / accordion / tabset) declares a `contentItem`, and the form creates its field grid inside it. The built-in grid's Repeater is emptied under a chrome, so each field exists once and its `field_` objectName stays unique -- prefill, resetFields() and name-based lookups keep working. A tabset chrome owns `currentIndex`; the form rebuilds the selected tab's fields on a switch exactly as the built-in tab bar does. A chrome submit button goes through `submit()`, so the `ready` guard still holds. Nothing registered, nothing changes: the existing 317 QML cases are untouched and green. The new tst_DynamicFormChrome.qml (13 cases) pins each role, and a form with every role registered showing no visible built-in Label, Button or TabBar. Restoring the section Repeater under a chrome reddens the fields-created-once case. forms_qml_logic: 330 passed on MSVC 14.51 / Qt 6.11.1. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 11 + docs/spec/forms/forms.md | 57 +++ src/qt/forms/qml/DynamicForm.qml | 251 ++++++++++++-- src/qt/forms/qml/SlotRegistry.qml | 25 ++ src/qt/forms/tests/tst_DynamicFormChrome.qml | 345 +++++++++++++++++++ 5 files changed, 668 insertions(+), 21 deletions(-) create mode 100644 src/qt/forms/tests/tst_DynamicFormChrome.qml diff --git a/CHANGELOG.md b/CHANGELOG.md index 1371ae119..03efcc7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,17 @@ API surface). `resolve()` takes it as an optional sixth argument, consulted after unit and before type. See `docs/spec/forms/forms.md`, "Theming / component-override registry" (fixes #812). +- **Chrome slots: a host replaces DynamicForm's labels, containers and buttons, + not only its controls.** `SlotRegistry.byChrome(role, component)` / + `resolveChrome(role)` register one Component per role — `fieldLabel`, + `fieldHelp`, `section`, `accordion` (falls back to `section`), `tabset`, + `header`, `status`, `submitButton`, `preview`, `result` — each loaded in place + of the built-in, which is hidden. Container chrome declares a `contentItem` + the form creates its field grid inside, so every field still exists once and + keeps its `field_` objectName. A label chrome gets live `required` and + `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". - **`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 8ab2b9c9f..1533cf5ab 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -1520,6 +1520,63 @@ and `byType` remains the fallback for a kind with none. Pinned by `src/qt/forms/tests/tst_SlotRegistryByKind.qml`; removing the tier from `resolve()` reddens 4 of its 5 cases. +### Chrome slots + +Field slots replace a field's *control*. Everything around the controls — the +caption, the help line, the section card, the tab bar, the heading, the status +line, the submit button, the JSON preview and the reply line — is chrome, and a +host whose visuals come from one UI kit needs to replace that too, or its forms +stay half-restyled. `SlotRegistry.byChrome(role, component)` registers one +Component per role, `resolveChrome(role)` returns it (or `null`), and +`DynamicForm` loads it **in place of** the built-in for that role — the built-in +is hidden, not drawn beside it: + +| Role | Replaces | Members assigned (each only if declared) | +|---|---|---| +| `fieldLabel` | the caption and red `*` of every field | `field`, `text` (label), `required` (live, includes `requiredWhen`), `invalid` (live: typed text that does not encode) | +| `fieldHelp` | the help line of a field that has one | `field`, `text` | +| `section` | a titled `"section"` group (and `"accordion"`, when no `accordion` chrome is registered) | `title`, `kind`, `section` (`{title, kind, fields}`); **must declare `contentItem`** | +| `accordion` | a collapsible `"accordion"` group | as `section`; collapsing is the chrome's own | +| `tabset` | a run of consecutive `"tab"` groups | `tabs` (`[{title}]`), reads the chrome's own `currentIndex`; **must declare `contentItem`** | +| `header` | the action-type heading | `text` | +| `status` | the "fill the required (\*) fields" / ready line | `text` (`DynamicForm.statusText`), `ready`, `reason` (`unrepresentableReason`), `explicitSubmit` | +| `submitButton` | the explicit-mode Submit button | `ready`, `submit()` | +| `preview` | the monospace JSON preview | `text` (`previewLine`) | +| `result` | the `ok:`/`err:` reply line | `text`, `ok` | + +Every chrome item is also offered `form` (the `DynamicForm`). Values that change +are assigned as bindings. A role with no registration keeps the built-in +exactly, so an app that registers nothing sees no change. To **remove** a piece +of chrome, register an empty `Item`; `preview` and `result` chrome are loaded +whatever their text, so an app that wants them decides itself when an empty one +shows. + +**Container chrome hosts the fields; it does not re-create them.** For +`section`, `accordion` and `tabset`, the form creates its field grid (two +columns, `x-colspan` honoured, the same field delegates) as a child of the +chrome's `contentItem`, which is expected to be a Layout — a `ColumnLayout` is +enough. The built-in grid's `Repeater` is emptied under a chrome, so each field +exists once and its `field_` `objectName` stays unique: prefill, +`resetFields()` and every test that finds a control by name keep working. A +`tabset` chrome owns `currentIndex`; the form shows the selected tab's fields +and, as with the built-in tab bar, rebuilds them on every switch (they re-seed +from `fieldValues`). The implicit untitled `"flat"` group has no chrome. + +`submit()` goes through `DynamicForm.submit()`, whose `ready` guard applies to a +chrome button exactly as to the built-in one. + +`DynamicForm` itself is a `Frame`: its outer border and padding are the +`Frame`'s `background` and `padding`, which a host sets on the instance +(`background: null`, `padding: 0`) — no slot is needed for them. The controls a +field slot does not replace are the style's own `QtQuick.Controls` types; no +style is imported, so they follow whichever style the application selects. + +`src/qt/forms/tests/tst_DynamicFormChrome.qml` pins each role — values handed +over, built-in hidden, fields created once inside a container — and a form with +every role registered showing no built-in `Label`, `Button` or `TabBar`. +Restoring the built-in section `Repeater` under a chrome reddens the +fields-created-once case. + The registry never appears in the schema or on the wire — two renderers of the same schema may register different slots. This is the "escape hatch always available" design principle ([above](#design-principle-infer-by-default-declare-to-override)) diff --git a/src/qt/forms/qml/DynamicForm.qml b/src/qt/forms/qml/DynamicForm.qml index 5f5f89b7c..3f43058d5 100644 --- a/src/qt/forms/qml/DynamicForm.qml +++ b/src/qt/forms/qml/DynamicForm.qml @@ -91,6 +91,51 @@ Frame { // as it does today. See SlotRegistry.qml. property var slotRegistry: null + // The chrome Component registered for `role`, or null for the built-in + // (docs/spec/forms/forms.md, "Chrome slots"). + function chrome(role) { + return slotRegistry ? slotRegistry.resolveChrome(role) : null + } + + // Assigns each of `values` to the same-named property of a chrome item -- + // only where the item declares it, so a chrome Component declares just the + // members it uses. A value may be a Qt.binding. `form` is always offered. + function bindChrome(item, values) { + if (!item) + return + if ("form" in item) + item.form = form + for (const key in values) { + if (key in item) + item[key] = values[key] + } + } + + // Whether the text held for `name` is typed but does not encode -- the + // "this field is wrong" state a host's label chrome may show. Blank is not + // invalid: that is the required gate's business. + function fieldInvalid(name) { + const f = fieldByName[name] + if (!f) + return false + return opt(fieldValues[name], "").trim() !== "" && fieldJsonLiteral(f) === null + } + + // The status line's text, shared by the built-in label and a status chrome. + readonly property string statusText: { + if (!ready) { + // Filling fields in is the usual remedy, but it is not the + // remedy for a member this renderer cannot represent, and + // telling the user to fill something that would not help + // is the worse half of the same lie a `ready` of true + // would be. Name the member instead. + if (unrepresentableReason !== "") + return "cannot be submitted -- " + unrepresentableReason + return "fill the required (*) fields" + } + return explicitSubmitMode ? "✓ ready -- press Submit" : "✓ executes automatically as you type" + } + property var fieldValues: ({}) property var fieldOptions: ({}) property var fieldUnits: ({}) @@ -1733,7 +1778,11 @@ Frame { enabled: { form.rulesRevision; return !form.fieldReadonly(fieldColumn.modelData.name) } spacing: 2 + property var labelChrome: form.chrome("fieldLabel") + property var helpChrome: form.chrome("fieldHelp") + RowLayout { + visible: fieldColumn.labelChrome === null Label { text: fieldColumn.modelData.label font.bold: true @@ -1745,13 +1794,45 @@ Frame { } } + // A host's label chrome replaces the row above: the caption, the + // live required marker and the invalid state are its to draw. + Loader { + active: fieldColumn.labelChrome !== null + visible: active + Layout.fillWidth: true + sourceComponent: fieldColumn.labelChrome + onLoaded: form.bindChrome(item, { + field: fieldColumn.modelData, + text: fieldColumn.modelData.label, + required: Qt.binding(function () { + form.rulesRevision + return fieldColumn.modelData.required || form.isDynamicallyRequired(fieldColumn.modelData.name) + }), + invalid: Qt.binding(function () { + form.rulesRevision + return form.fieldInvalid(fieldColumn.modelData.name) + }) + }) + } + Label { - visible: fieldColumn.modelData.description !== "" + visible: fieldColumn.helpChrome === null && fieldColumn.modelData.description !== "" text: fieldColumn.modelData.description opacity: 0.6 font.pixelSize: 12 } + Loader { + active: fieldColumn.helpChrome !== null && fieldColumn.modelData.description !== "" + visible: active + Layout.fillWidth: true + sourceComponent: fieldColumn.helpChrome + onLoaded: form.bindChrome(item, { + field: fieldColumn.modelData, + text: fieldColumn.modelData.description + }) + } + RowLayout { id: controlsRow Layout.fillWidth: true @@ -2137,9 +2218,12 @@ Frame { property var runData Layout.fillWidth: true property bool collapsed: false + // The implicit "flat" bucket has no chrome to replace. + property var sectionChrome: (!box.runData || box.runData.section.kind === "flat") + ? null : form.chrome(box.runData.section.kind) RowLayout { - visible: box.runData.section.title !== "" + visible: box.sectionChrome === null && box.runData.section.title !== "" Layout.fillWidth: true Button { @@ -2157,17 +2241,69 @@ Frame { GridLayout { Layout.fillWidth: true - visible: !box.collapsed + visible: box.sectionChrome === null && !box.collapsed columns: box.runData.section.kind === "flat" ? 1 : 2 + // Empty under a chrome: the fields are created inside it + // instead, and one delegate per field is what keeps every + // objectName unique. Repeater { - model: box.runData.section.fields + model: box.sectionChrome === null ? box.runData.section.fields : [] delegate: fieldDelegate } } + + // A host's section chrome (for "section", and for "accordion" + // unless one is registered for it): the card, the heading and any + // collapsing are its own; this form creates the field grid inside + // the chrome's `contentItem`. + Loader { + active: box.sectionChrome !== null + visible: active + Layout.fillWidth: true + sourceComponent: box.sectionChrome + onLoaded: { + form.bindChrome(item, { + title: box.runData.section.title, + kind: box.runData.section.kind, + section: box.runData.section + }) + form.createFieldGrid(item, 2, function () { return box.runData.section.fields }) + } + } } } + // The field grid a section or tab-set chrome hosts, created inside the + // chrome's `contentItem` (expected to be a Layout, e.g. a ColumnLayout). + Component { + id: chromeFieldGrid + + GridLayout { + id: chromeGrid + property var gridFields: [] + Layout.fillWidth: true + + Repeater { + model: chromeGrid.gridFields + delegate: fieldDelegate + } + } + } + + // `fieldsOf` is a function so the grid follows a binding (a tab-set + // chrome's currentIndex) rather than a snapshot. + function createFieldGrid(chromeItem, columns, fieldsOf) { + if (!chromeItem || !chromeItem.contentItem) { + console.warn("DynamicForm: a section/tabset chrome must declare `contentItem`; its fields are not shown") + return null + } + return chromeFieldGrid.createObject(chromeItem.contentItem, { + columns: columns, + gridFields: Qt.binding(fieldsOf) + }) + } + Component { id: tabsetRun @@ -2179,10 +2315,13 @@ Frame { property var runData Layout.fillWidth: true property int currentTab: 0 + // Null until the run is assigned, so the chrome never loads without it. + property var tabsetChrome: tabsBox.runData ? form.chrome("tabset") : null TabBar { id: bar objectName: "tabBar" + visible: tabsBox.tabsetChrome === null Layout.fillWidth: true currentIndex: tabsBox.currentTab onCurrentIndexChanged: tabsBox.currentTab = currentIndex @@ -2198,13 +2337,36 @@ Frame { GridLayout { Layout.fillWidth: true + visible: tabsBox.tabsetChrome === null columns: 2 Repeater { - model: tabsBox.runData.sections[tabsBox.currentTab].fields + model: tabsBox.tabsetChrome === null ? tabsBox.runData.sections[tabsBox.currentTab].fields : [] delegate: fieldDelegate } } + + // A host's tab-set chrome: it draws the tabs from `tabs` and owns + // `currentIndex`; this form shows the selected tab's fields inside + // its `contentItem`, rebuilt on every switch exactly as the + // built-in tab bar's grid is. + Loader { + active: tabsBox.tabsetChrome !== null + visible: active + Layout.fillWidth: true + sourceComponent: tabsBox.tabsetChrome + onLoaded: { + const chromeItem = item + form.bindChrome(chromeItem, { + tabs: tabsBox.runData.sections.map(function (section) { return { title: section.title } }) + }) + form.createFieldGrid(chromeItem, 2, function () { + const index = ("currentIndex" in chromeItem) ? chromeItem.currentIndex : 0 + const section = tabsBox.runData.sections[index] + return section ? section.fields : [] + }) + } + } } } @@ -2216,11 +2378,20 @@ Frame { spacing: 4 Label { + visible: form.chrome("header") === null text: form.actionType font.bold: true font.pixelSize: 16 } + Loader { + active: form.chrome("header") !== null + visible: active + Layout.fillWidth: true + sourceComponent: form.chrome("header") + onLoaded: form.bindChrome(item, { text: Qt.binding(function () { return form.actionType }) }) + } + Repeater { model: form.renderRuns @@ -2235,19 +2406,8 @@ Frame { Label { Layout.topMargin: 8 - text: { - if (!form.ready) { - // Filling fields in is the usual remedy, but it is not the - // remedy for a member this renderer cannot represent, and - // telling the user to fill something that would not help - // is the worse half of the same lie a `ready` of true - // would be. Name the member instead. - if (form.unrepresentableReason !== "") - return "cannot be submitted -- " + form.unrepresentableReason - return "fill the required (*) fields" - } - return form.explicitSubmitMode ? "✓ ready -- press Submit" : "✓ executes automatically as you type" - } + visible: form.chrome("status") === null + text: form.statusText opacity: 0.6 font.italic: true // A blocked submit is announced, not merely tinted (docs/spec/ @@ -2259,6 +2419,19 @@ Frame { Accessible.description: text } + Loader { + active: form.chrome("status") !== null + visible: active + Layout.fillWidth: true + sourceComponent: form.chrome("status") + onLoaded: form.bindChrome(item, { + text: Qt.binding(function () { return form.statusText }), + ready: Qt.binding(function () { return form.ready }), + reason: Qt.binding(function () { return form.unrepresentableReason }), + explicitSubmit: Qt.binding(function () { return form.explicitSubmitMode }) + }) + } + // "x-submitMode": "explicit" (docs/spec/forms/forms.md, "Explicit // submit mode"): the sole trigger for a side-effectful action's // submission. Enabled only while `ready`, matching the required (*) @@ -2270,7 +2443,21 @@ Frame { Loader { active: form.explicitSubmitMode Layout.topMargin: 4 - sourceComponent: Button { + sourceComponent: form.chrome("submitButton") !== null ? form.chrome("submitButton") : builtinSubmitButton + // A host's submit chrome gets `ready` and `submit()`; the form's + // own guard in submit() still applies to it. + onLoaded: { + if (sourceComponent !== builtinSubmitButton) + form.bindChrome(item, { + ready: Qt.binding(function () { return form.ready }), + submit: function () { form.submit() } + }) + } + } + + Component { + id: builtinSubmitButton + Button { id: submitButton objectName: "submitButton" enabled: form.ready @@ -2280,7 +2467,7 @@ Frame { } Label { - visible: form.previewLine !== "" + visible: form.chrome("preview") === null && form.previewLine !== "" Layout.fillWidth: true text: form.previewLine wrapMode: Text.WrapAnywhere @@ -2290,7 +2477,7 @@ Frame { } Label { - visible: form.resultText !== "" + visible: form.chrome("result") === null && form.resultText !== "" Layout.fillWidth: true text: (form.resultOk ? "ok: " : "err: ") + form.resultText wrapMode: Text.WrapAnywhere @@ -2298,6 +2485,28 @@ Frame { font.pixelSize: 12 color: form.resultOk ? palette.text : "#d33" } + + // Preview and result chrome are loaded whatever their text: a host + // that wants neither registers an empty Item, and one that wants them + // decides for itself when to show an empty one. + Loader { + active: form.chrome("preview") !== null + visible: active + Layout.fillWidth: true + sourceComponent: form.chrome("preview") + onLoaded: form.bindChrome(item, { text: Qt.binding(function () { return form.previewLine }) }) + } + + Loader { + active: form.chrome("result") !== null + visible: active + Layout.fillWidth: true + sourceComponent: form.chrome("result") + onLoaded: form.bindChrome(item, { + text: Qt.binding(function () { return form.resultText }), + ok: Qt.binding(function () { return form.resultOk }) + }) + } } Component.onCompleted: { diff --git a/src/qt/forms/qml/SlotRegistry.qml b/src/qt/forms/qml/SlotRegistry.qml index d704480f3..805465394 100644 --- a/src/qt/forms/qml/SlotRegistry.qml +++ b/src/qt/forms/qml/SlotRegistry.qml @@ -7,6 +7,11 @@ // schema or on the wire, and two renderers of the same schema may register // different slots. DynamicForm consults byField/byWidget/byUnit/byKind/byType // in that priority order and falls back to its own built-in control on a miss. +// +// byChrome registers the form's *chrome* instead -- the field label, help +// text, section/accordion/tab containers, header, status line, submit button, +// preview and result -- one Component per role, each replacing the built-in +// one wholesale (docs/spec/forms/forms.md, "Chrome slots"). import QtQuick @@ -18,6 +23,7 @@ QtObject { property var _byUnit: ({}) // unitAscii -> Component property var _byKind: ({}) // renderer kind ("quantity", "choice", ...) -> Component property var _byType: ({}) // JSON type ("integer", "string", ...) -> Component + property var _byChrome: ({}) // chrome role ("fieldLabel", "section", ...) -> Component // Bumped on every by*() registration. `_byField`/`_byWidget`/`_byUnit`/ // `_byType` are plain JS objects mutated in place (obj[key] = value); @@ -71,6 +77,25 @@ QtObject { revision++ } + /// Registers @p component as the form's chrome for @p role: one of + /// "fieldLabel", "fieldHelp", "section", "accordion", "tabset", "header", + /// "status", "submitButton", "preview", "result". An unknown role is + /// stored and never asked for. + function byChrome(role, component) { + _byChrome[role] = component + revision++ + } + + /// The Component registered for chrome @p role, or null (built-in). An + /// "accordion" with no registration of its own falls back to "section": a + /// host with one container card gets it for both kinds. + function resolveChrome(role) { + registry.revision + if (_byChrome[role] !== undefined) return _byChrome[role] + if (role === "accordion" && _byChrome["section"] !== undefined) return _byChrome["section"] + return null + } + /// Resolution order: field -> x-widget -> unit -> kind -> type -> null /// (built-in). Returns the first matching Component, or null on a total /// miss (DynamicForm then renders its own built-in control). @p kind is diff --git a/src/qt/forms/tests/tst_DynamicFormChrome.qml b/src/qt/forms/tests/tst_DynamicFormChrome.qml new file mode 100644 index 000000000..38dfbd4e2 --- /dev/null +++ b/src/qt/forms/tests/tst_DynamicFormChrome.qml @@ -0,0 +1,345 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Chrome slots: a host replaces the form's own chrome -- field label, help +// text, section / accordion / tab-set containers, header, status line, submit +// button, preview and result -- through SlotRegistry.byChrome, so a form built +// from a host's UI kit shows no built-in Label or Button of its own. +// +// Each case asserts both halves: the chrome is loaded and handed its values, +// *and* the built-in it replaces is gone -- a chrome drawn beside a still- +// visible built-in is exactly the half-restyled form this seam exists to end. +// The fields a container chrome hosts are asserted to be created once, inside +// it: a second, hidden copy would claim the same objectName. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtTest +import MorphForms + +TestCase { + id: testCase + name: "DynamicFormChrome" + visible: true + width: 600 + height: 800 + + QtObject { + id: recordingController + property int submissions: 0 + property string lastBody: "" + signal replyReceived(string actionType, bool ok, string payload) + function submitIfValid(actionType, bodyJson) { + submissions++ + lastBody = bodyJson + replyReceived(actionType, true, '{"ok":true}') + } + } + + property var flatSchema: ({ + properties: { + count: { type: "integer", "x-order": 0, title: "Count", description: "How many" }, + note: { type: ["string", "null"], "x-order": 1, title: "Note" } + }, + required: ["count"] + }) + + property var explicitSchema: ({ + properties: { count: { type: "integer", "x-order": 0, title: "Count" } }, + required: ["count"], + "x-submitMode": "explicit" + }) + + property var layoutSchema: ({ + properties: { + a: { type: ["integer", "null"], "x-order": 0, "x-section": 0 }, + b: { type: ["integer", "null"], "x-order": 1, "x-section": 1 }, + c: { type: ["integer", "null"], "x-order": 2, "x-section": 2 }, + d: { type: ["integer", "null"], "x-order": 3, "x-section": 3 } + }, + required: [], + "x-layout": { + groups: [ + { title: "Identity", kind: "section", fields: ["a"] }, + { title: "More", kind: "accordion", fields: ["b"] }, + { title: "One", kind: "tab", fields: ["c"] }, + { title: "Two", kind: "tab", fields: ["d"] } + ] + } + }) + + // ── chrome components, each recording what it was handed ───────────────── + + Component { + id: labelChrome + Item { + objectName: "labelChrome" + property var field + property string text + property bool required + property bool invalid + property var form + } + } + + Component { + id: helpChrome + Item { + objectName: "helpChrome" + property string text + } + } + + Component { + id: sectionChrome + ColumnLayout { + objectName: "sectionChrome" + property string title + property string kind + property var section + property alias contentItem: body + ColumnLayout { id: body; objectName: "sectionBody"; Layout.fillWidth: true } + } + } + + Component { + id: accordionChrome + ColumnLayout { + objectName: "accordionChrome" + property string title + property alias contentItem: body + ColumnLayout { id: body; Layout.fillWidth: true } + } + } + + Component { + id: tabsetChrome + ColumnLayout { + objectName: "tabsetChrome" + property var tabs: [] + property int currentIndex: 0 + property alias contentItem: body + ColumnLayout { id: body; objectName: "tabsetBody"; Layout.fillWidth: true } + } + } + + Component { + id: headerChrome + Item { objectName: "headerChrome"; property string text } + } + + Component { + id: statusChrome + Item { + objectName: "statusChrome" + property string text + property bool ready + property string reason + property bool explicitSubmit + } + } + + Component { + id: submitChrome + Item { + objectName: "submitChrome" + property bool ready + property var submit + } + } + + Component { + id: previewChrome + Item { objectName: "previewChrome"; property string text } + } + + Component { + id: resultChrome + Item { objectName: "resultChrome"; property string text; property bool ok } + } + + Component { + id: registryComponent + SlotRegistry {} + } + + Component { + id: formComponent + DynamicForm { actionType: "T_Chrome"; controller: null } + } + + function makeForm(schema, chromes, controller) { + const registry = createTemporaryObject(registryComponent, testCase) + for (const role in chromes) + registry.byChrome(role, chromes[role]) + return createTemporaryObject(formComponent, testCase, + { schema: schema, slotRegistry: registry, controller: controller || null }) + } + + // Every visible Label or Button under `item` -- what a fully restyled form + // must not contain. + function visibleBuiltins(item, out) { + if (!item) + return out + if (item.visible === false) + return out + if (item instanceof Label || item instanceof Button || item instanceof TabBar) + out.push(item) + const kids = item.children || [] + for (let i = 0; i < kids.length; ++i) + visibleBuiltins(kids[i], out) + return out + } + + function countNamed(item, name) { + if (!item) + return 0 + let n = item.objectName === name ? 1 : 0 + const kids = item.children || [] + for (let i = 0; i < kids.length; ++i) + n += countNamed(kids[i], name) + return n + } + + // ── registry ───────────────────────────────────────────────────────────── + + function test_resolve_chrome_misses_to_null_and_accordion_falls_back() { + const registry = createTemporaryObject(registryComponent, testCase) + compare(registry.resolveChrome("section"), null) + registry.byChrome("section", sectionChrome) + compare(registry.resolveChrome("section"), sectionChrome) + compare(registry.resolveChrome("accordion"), sectionChrome) + registry.byChrome("accordion", accordionChrome) + compare(registry.resolveChrome("accordion"), accordionChrome) + compare(registry.resolveChrome("tabset"), null) + } + + // ── defaults unchanged ─────────────────────────────────────────────────── + + function test_without_chrome_the_built_ins_are_drawn() { + const form = createTemporaryObject(formComponent, testCase, { schema: testCase.flatSchema }) + verify(visibleBuiltins(form, []).length > 0) + compare(findChild(form, "labelChrome"), null) + } + + // ── field chrome ───────────────────────────────────────────────────────── + + function test_label_chrome_gets_the_caption_and_a_live_required_and_invalid_state() { + const form = makeForm(testCase.flatSchema, { fieldLabel: labelChrome }) + const first = findChild(form, "labelChrome") + verify(first !== null) + compare(first.field.name, "count") + compare(first.text, "Count") + compare(first.required, true) + compare(first.invalid, false) + verify(first.form === form) + findChild(form, "field_count").text = "abc" + compare(first.invalid, true) + findChild(form, "field_count").text = "3" + compare(first.invalid, false) + compare(countNamed(form, "labelChrome"), 2) + } + + function test_help_chrome_is_loaded_only_where_there_is_help() { + const form = makeForm(testCase.flatSchema, { fieldHelp: helpChrome }) + compare(countNamed(form, "helpChrome"), 1) + compare(findChild(form, "helpChrome").text, "How many") + } + + // ── container chrome ───────────────────────────────────────────────────── + + function test_section_chrome_hosts_its_fields_once() { + const form = makeForm(testCase.layoutSchema, { section: sectionChrome }) + const first = findChild(form, "sectionChrome") + verify(first !== null) + compare(first.title, "Identity") + compare(first.kind, "section") + // The field sits inside the chrome, and nowhere else. + verify(findChild(first, "field_a") !== null) + compare(countNamed(form, "field_a"), 1) + // The accordion falls back to the section chrome. + compare(countNamed(form, "sectionChrome"), 2) + compare(countNamed(form, "field_b"), 1) + } + + function test_an_accordion_chrome_of_its_own_wins() { + const form = makeForm(testCase.layoutSchema, { section: sectionChrome, accordion: accordionChrome }) + const accordion = findChild(form, "accordionChrome") + verify(accordion !== null) + compare(accordion.title, "More") + verify(findChild(accordion, "field_b") !== null) + compare(countNamed(form, "sectionChrome"), 1) + } + + function test_a_field_inside_a_chrome_still_drives_the_form() { + const form = makeForm(testCase.layoutSchema, { section: sectionChrome }) + findChild(form, "field_a").text = "5" + compare(form.ready, true) + compare(form.previewLine, '{"a":5}') + } + + function test_tabset_chrome_owns_the_tabs_and_the_selection() { + const form = makeForm(testCase.layoutSchema, { tabset: tabsetChrome }) + const tabs = findChild(form, "tabsetChrome") + verify(tabs !== null) + compare(tabs.tabs.length, 2) + compare(tabs.tabs[0].title, "One") + compare(tabs.tabs[1].title, "Two") + compare(countNamed(form, "tabBar"), 1) // the built-in, hidden + verify(!findChild(form, "tabBar").visible) + verify(findChild(tabs, "field_c") !== null) + compare(countNamed(form, "field_d"), 0) + tabs.currentIndex = 1 + tryVerify(function () { return findChild(tabs, "field_d") !== null }) + compare(countNamed(form, "field_c"), 0) + } + + // ── form chrome ────────────────────────────────────────────────────────── + + function test_header_status_preview_and_result_chrome() { + const form = makeForm(testCase.flatSchema, + { header: headerChrome, status: statusChrome, preview: previewChrome, + result: resultChrome }, + recordingController) + compare(findChild(form, "headerChrome").text, "T_Chrome") + const status = findChild(form, "statusChrome") + compare(status.ready, false) + compare(status.text, "fill the required (*) fields") + findChild(form, "field_count").text = "4" + compare(status.ready, true) + compare(status.text, form.statusText) + compare(findChild(form, "previewChrome").text, '{"count":4}') + // Auto-submit fired; the reply reaches the result chrome. + compare(findChild(form, "resultChrome").ok, true) + compare(findChild(form, "resultChrome").text, '{"ok":true}') + } + + function test_submit_chrome_fires_through_the_forms_own_gate() { + recordingController.submissions = 0 + const form = makeForm(testCase.explicitSchema, { submitButton: submitChrome }, recordingController) + const submit = findChild(form, "submitChrome") + verify(submit !== null) + compare(findChild(form, "submitButton"), null) + compare(submit.ready, false) + submit.submit() + compare(recordingController.submissions, 0) // not ready: the gate holds + findChild(form, "field_count").text = "2" + compare(submit.ready, true) + submit.submit() + compare(recordingController.submissions, 1) + compare(recordingController.lastBody, '{"count":2}') + } + + // ── all of it: nothing built-in left ───────────────────────────────────── + + function test_a_fully_chromed_form_shows_no_built_in_label_or_button() { + const form = makeForm(testCase.layoutSchema, + { fieldLabel: labelChrome, fieldHelp: helpChrome, section: sectionChrome, + tabset: tabsetChrome, header: headerChrome, status: statusChrome, + preview: previewChrome, result: resultChrome }) + const left = visibleBuiltins(form, []) + compare(left.length, 0, "still built-in: " + left.map(function (i) { return i.toString() }).join(", ")) + } +} From 14e60d8bc557ce9a0519bfe2e46030723ed454cd Mon Sep 17 00:00:00 2001 From: Yaraslau Date: Fri, 25 Sep 2026 09:15:06 +0300 Subject: [PATCH 2/2] forms(qml): CollectionView and WizardView chrome through SlotRegistry.byChrome, and the registry reaches the forms they embed (fixes #813) (#819) hard-coded theirs -- title, column headers, row cells, Open / row-action / collection-action buttons, the "Are you sure?" and editor Dialogs, Back / Next and the step counter -- and, more to the point, never gave the DynamicForms they embed a slotRegistry at all: a host's field slots and form chrome stopped at the view's edge, so even a fully registered kit drew built-in editors. - `slotRegistry` on both views, handed to modalForm / detailForm and to every wizard step. - CollectionView roles: collectionHeader (title, columns, actions, fire), collectionRow (row, columns, cells, rowKey, actions, canOpen, open, fire), confirmDialog (message, action, row, accept, reject -- loaded only while an action waits) and editorDialog (title, open, close, and a contentItem the editor form is reparented into). - WizardView roles: wizardHeader (title, stepIndex, stepCount, stepTitle) and wizardNav (canBack, canNext, lastStep, back, next). next() keeps the built-in gate: the step's action must have replied ok. - DateTimePicker gets no chrome: a host replaces the whole picker with a field slot, byKind("datetime") from #816. Nothing registered, nothing changes: tst_collectionview.qml and tst_wizardview.qml are untouched and green. tst_ViewChrome.qml (9 cases) pins each role, the embedded forms receiving the registry, and a fully chromed collection screen with the editor open showing no visible built-in Label or Button. Dropping the registry hand-off and the row-chrome swap reddens 3 of them. forms_qml_logic: 341 passed on MSVC 14.51 / Qt 6.11.1. Stacked on #810 (byChrome / resolveChrome). Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 12 ++ docs/spec/forms/forms.md | 9 + docs/spec/forms/views.md | 22 ++ docs/spec/forms/workflows_navigation.md | 15 ++ src/qt/forms/qml/CollectionView.qml | 121 ++++++++++- src/qt/forms/qml/SlotRegistry.qml | 8 +- src/qt/forms/qml/WizardView.qml | 77 ++++++- src/qt/forms/tests/tst_ViewChrome.qml | 263 ++++++++++++++++++++++++ 8 files changed, 518 insertions(+), 9 deletions(-) create mode 100644 src/qt/forms/tests/tst_ViewChrome.qml diff --git a/CHANGELOG.md b/CHANGELOG.md index 03efcc7dd..1a328e207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,18 @@ API surface). `resolve()` takes it as an optional sixth argument, consulted after unit and before type. See `docs/spec/forms/forms.md`, "Theming / component-override registry" (fixes #812). +- **CollectionView and WizardView chrome goes through `SlotRegistry.byChrome`, + and both hand their registry to the forms they embed.** Their title, column + headers, row cells and buttons, confirm and editor dialogs, and Back/Next were + hard-coded, and the editor/step `DynamicForm`s got no `slotRegistry` at all, so + a host's field slots and form chrome stopped at the view's edge. New + `slotRegistry` properties and roles `collectionHeader`, `collectionRow`, + `confirmDialog`, `editorDialog` (a `contentItem` the editor is reparented + into), `wizardHeader` and `wizardNav`; the chrome's `fire()`/`accept()`/ + `next()` make exactly the built-in buttons' calls, gates included. See + `docs/spec/forms/views.md` and `workflows_navigation.md`, "Chrome slots" + (fixes #813). + - **Chrome slots: a host replaces DynamicForm's labels, containers and buttons, not only its controls.** `SlotRegistry.byChrome(role, component)` / `resolveChrome(role)` register one Component per role — `fieldLabel`, diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index 1533cf5ab..a8104d75f 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -1544,6 +1544,15 @@ is hidden, not drawn beside it: | `preview` | the monospace JSON preview | `text` (`previewLine`) | | `result` | the `ok:`/`err:` reply line | `text`, `ok` | +`CollectionView` and `WizardView` read the same registry for chrome roles of +their own (`collectionHeader`, `collectionRow`, `confirmDialog`, +`editorDialog`; `wizardHeader`, `wizardNav` — see +[views.md](views.md#chrome-slots-and-the-embedded-editors) and +[workflows_navigation.md](workflows_navigation.md#chrome-slots)), and hand it on +to every `DynamicForm` they embed. `DateTimePicker` has no chrome of its own: a +host replaces the whole picker with a field slot (`byKind("datetime", …)`, +#812). + Every chrome item is also offered `form` (the `DynamicForm`). Values that change are assigned as bindings. A role with no registration keeps the built-in exactly, so an app that registers nothing sees no change. To **remove** a piece diff --git a/docs/spec/forms/views.md b/docs/spec/forms/views.md index 53cf07a39..c63a81899 100644 --- a/docs/spec/forms/views.md +++ b/docs/spec/forms/views.md @@ -298,6 +298,28 @@ the per-action forms, excluding from the standalone-forms list any action a view already owns (its query, row-opener, or a `v-actions` target), so nothing renders twice. +### Chrome slots and the embedded editors + +`CollectionView.slotRegistry` (null by default) is handed to both editor +`DynamicForm`s, so field slots and form chrome registered once apply inside +the editor too. The view's own chrome goes through the same +`SlotRegistry.byChrome` registry ([forms.md, "Chrome slots"](forms.md#chrome-slots)), +each role replacing the built-in, which is then not drawn; members are +assigned only where the chrome declares them: + +| Role | Replaces | Members | +|---|---|---| +| `collectionHeader` | the title and the column-header row with its collection actions | `title`, `columns` (visible `v-columns`), `actions` (collection scope), `fire(action)` | +| `collectionRow` | one row's cells, Open button and row actions | `row`, `columns`, `cells` (formatted texts), `rowKey`, `actions` (row scope), `canOpen`, `open()`, `fire(action)` | +| `confirmDialog` | the "Are you sure?" dialog | `message`, `action`, `row`, `accept()`, `reject()` — loaded only while an action waits | +| `editorDialog` | the collection kind's modal editor | `title`, `open`, `close()`; **must declare `contentItem`**, into which the view reparents the editor form | + +`fire()` and `accept()` make exactly the controller calls the built-in buttons +make (confirmation included), and `open()` runs the same prefill. The root is +a `Frame`, whose `background`/`padding` a host sets on the instance. +`src/qt/forms/tests/tst_ViewChrome.qml` pins every role and a fully chromed +screen, editor open, with no visible built-in `Label` or `Button`. + ## API reference ### `morph::views::viewSchemaJson()` diff --git a/docs/spec/forms/workflows_navigation.md b/docs/spec/forms/workflows_navigation.md index 668b3e133..2a78cf0d9 100644 --- a/docs/spec/forms/workflows_navigation.md +++ b/docs/spec/forms/workflows_navigation.md @@ -285,6 +285,21 @@ over) draft keys on a name collision, mirroring `FlowSession::captureResult`'s precedence exactly, just implemented as plain JSON manipulation instead of the typed template API (see [Design decisions](#design-decisions)). +### Chrome slots + +`WizardView.slotRegistry` (null by default) is handed to every step's +`DynamicForm`, and two chrome roles replace the stepper's own chrome +([forms.md, "Chrome slots"](forms.md#chrome-slots)): + +| Role | Replaces | Members | +|---|---|---| +| `wizardHeader` | the "title (n / m)" heading | `title`, `stepIndex`, `stepCount`, `stepTitle` | +| `wizardNav` | Back / Next and the last-step note | `canBack`, `canNext`, `lastStep`, `stepIndex`, `stepCount`, `back()`, `next()` | + +`next()` is gated by `canNext` — the current step's action must have replied +ok — exactly as the built-in Next button is, and runs the same prefill. +Pinned by `src/qt/forms/tests/tst_ViewChrome.qml`. + ## API reference ### `morph::flows` diff --git a/src/qt/forms/qml/CollectionView.qml b/src/qt/forms/qml/CollectionView.qml index d11abbfb0..d29f3b015 100644 --- a/src/qt/forms/qml/CollectionView.qml +++ b/src/qt/forms/qml/CollectionView.qml @@ -32,6 +32,30 @@ Frame { property var rows: [] property var editorRow: null // the row currently open for edit, or null + // Client-side slots and chrome, handed on to both editor forms. null (the + // default) draws every built-in, as before. + property var slotRegistry: null + + // A confirm-guarded action awaiting the host's confirm chrome, or null. + property var pendingConfirm: null + + // The host's chrome for `role`, or null for the built-in -- the registry + // DynamicForm reads (docs/spec/forms/forms.md, "Chrome slots"). + function chrome(role) { + return slotRegistry ? slotRegistry.resolveChrome(role) : null + } + + // Assigns each of `values` to a chrome item's same-named property, only + // where the item declares it (DynamicForm.bindChrome's rule). + function bindChrome(item, values) { + if (!item) + return + for (const key in values) { + if (key in item) + item[key] = values[key] + } + } + readonly property bool isMasterDetail: root.view["v-kind"] === "master-detail" readonly property var visibleColumns: (root.view["v-columns"] || []).filter(c => !c["v-hidden"]) readonly property var rowScopeActions: (root.view["v-actions"] || []).filter(a => a.scope === "row") @@ -172,6 +196,10 @@ Frame { if (!root.controller) return if (descriptor.confirm) { + if (root.chrome("confirmDialog") !== null) { + root.pendingConfirm = { action: descriptor, row: row } + return + } confirmDialog.pendingAction = descriptor confirmDialog.pendingRow = row confirmDialog.open() @@ -232,6 +260,50 @@ Frame { } } + // A host's confirm chrome, loaded while a confirm-guarded action waits: + // `message`, `action` (the v-actions descriptor), `row`, and `accept()` / + // `reject()`. + Loader { + active: root.pendingConfirm !== null && root.chrome("confirmDialog") !== null + sourceComponent: root.chrome("confirmDialog") + onLoaded: root.bindChrome(item, { + message: "Are you sure?", + action: root.pendingConfirm.action, + row: root.pendingConfirm.row, + accept: function () { + const pending = root.pendingConfirm + root.pendingConfirm = null + if (pending && root.controller) + root.controller.submitIfValid(pending.action.action, + root.bindBodyJson(pending.action.bind || {}, pending.row)) + }, + reject: function () { root.pendingConfirm = null } + }) + } + + // A host's editor chrome for the collection kind: it shows the row + // editor, which this view reparents into its `contentItem`, while + // `open` is true, and calls `close()` to dismiss it. + property var editorChrome: root.chrome("editorDialog") + Loader { + id: editorChromeLoader + active: root.editorChrome !== null && !root.isMasterDetail + sourceComponent: root.editorChrome + onLoaded: { + root.bindChrome(item, { + title: Qt.binding(function () { + return root.view["v-rowAction"] ? String(root.view["v-rowAction"].action) : "" + }), + open: Qt.binding(function () { return root.editorRow !== null }), + close: function () { root.closeEditor() } + }) + if (item.contentItem) + modalForm.parent = item.contentItem + else + console.warn("CollectionView: an editorDialog chrome must declare `contentItem`") + } + } + // Collection kind: the row editor is a modal (list-plus-modal fallback of // the master-detail split — docs/spec/forms/views.md, "Design // decisions"). Master-detail vs. collection is a rendering choice, not a @@ -239,7 +311,7 @@ Frame { Dialog { id: editorDialog objectName: "editorDialog" - visible: !root.isMasterDetail && root.editorRow !== null + visible: root.editorChrome === null && !root.isMasterDetail && root.editorRow !== null modal: true title: root.view["v-rowAction"] ? String(root.view["v-rowAction"].action) : "" onClosed: root.closeEditor() @@ -249,6 +321,7 @@ Frame { actionType: root.view["v-rowAction"] ? root.view["v-rowAction"].action : "" schema: root.schemas[modalForm.actionType] || ({}) controller: root.controller + slotRegistry: root.slotRegistry } } @@ -262,12 +335,30 @@ Frame { spacing: 4 Label { + visible: root.chrome("collectionHeader") === null text: root.opt(root.view["v-title"], root.viewId) font.bold: true font.pixelSize: 16 } + // A host's header chrome replaces the title and the column-header + // row with its collection actions: `title`, `columns`, `actions` + // and `fire(action)`. + Loader { + active: root.chrome("collectionHeader") !== null + visible: active + Layout.fillWidth: true + sourceComponent: root.chrome("collectionHeader") + onLoaded: root.bindChrome(item, { + title: Qt.binding(function () { return root.opt(root.view["v-title"], root.viewId) }), + columns: Qt.binding(function () { return root.visibleColumns }), + actions: Qt.binding(function () { return root.collectionScopeActions }), + fire: function (descriptor) { root.fireCollectionAction(descriptor) } + }) + } + RowLayout { + visible: root.chrome("collectionHeader") === null spacing: 8 Repeater { @@ -291,8 +382,33 @@ Frame { } } + // A host's row chrome replaces each row's cells and buttons: + // `row`, `columns`, `cells` (formatted texts, per visible column), + // `rowKey`, `actions`, `canOpen`, `open()` and `fire(action)`. + Repeater { + model: root.chrome("collectionRow") !== null ? root.rows : [] + Loader { + id: rowChromeLoader + required property var modelData + Layout.fillWidth: true + sourceComponent: root.chrome("collectionRow") + onLoaded: root.bindChrome(item, { + row: rowChromeLoader.modelData, + columns: root.visibleColumns, + cells: root.visibleColumns.map(function (column) { + return root.formatCell(rowChromeLoader.modelData, column) + }), + rowKey: JsonExact.text(rowChromeLoader.modelData[root.opt(root.view["v-rowKey"], "id")]), + actions: root.rowScopeActions, + canOpen: root.view["v-rowAction"] !== undefined, + open: function () { root.openEditor(rowChromeLoader.modelData) }, + fire: function (descriptor) { root.fireRowAction(descriptor, rowChromeLoader.modelData) } + }) + } + } + Repeater { - model: root.rows + model: root.chrome("collectionRow") === null ? root.rows : [] RowLayout { id: rowDelegate required property var modelData @@ -344,6 +460,7 @@ Frame { actionType: root.view["v-rowAction"] ? root.view["v-rowAction"].action : "" schema: root.schemas[detailForm.actionType] || ({}) controller: root.controller + slotRegistry: root.slotRegistry } } diff --git a/src/qt/forms/qml/SlotRegistry.qml b/src/qt/forms/qml/SlotRegistry.qml index 805465394..5bf6083f7 100644 --- a/src/qt/forms/qml/SlotRegistry.qml +++ b/src/qt/forms/qml/SlotRegistry.qml @@ -77,10 +77,12 @@ QtObject { revision++ } - /// Registers @p component as the form's chrome for @p role: one of + /// Registers @p component as chrome for @p role: DynamicForm's /// "fieldLabel", "fieldHelp", "section", "accordion", "tabset", "header", - /// "status", "submitButton", "preview", "result". An unknown role is - /// stored and never asked for. + /// "status", "submitButton", "preview", "result"; CollectionView's + /// "collectionHeader", "collectionRow", "confirmDialog", "editorDialog"; + /// WizardView's "wizardHeader", "wizardNav". An unknown role is stored and + /// never asked for. function byChrome(role, component) { _byChrome[role] = component revision++ diff --git a/src/qt/forms/qml/WizardView.qml b/src/qt/forms/qml/WizardView.qml index d3088e1a2..208ede3d2 100644 --- a/src/qt/forms/qml/WizardView.qml +++ b/src/qt/forms/qml/WizardView.qml @@ -39,6 +39,32 @@ Frame { property var steps: wizardSchema["w-steps"] || [] property int currentIndex: 0 + // Client-side slots and chrome, handed on to every step's form. null (the + // default) draws every built-in, as before. + property var slotRegistry: null + + readonly property string headerTitle: (wizard.wizardSchema["w-title"] || wizard.wizardId) + readonly property bool canGoBack: wizard.currentIndex > 0 + readonly property bool canGoNext: wizard.currentIndex < wizard.steps.length - 1 && wizard.currentStepDone + readonly property bool onLastStep: wizard.currentIndex >= wizard.steps.length - 1 + + // The host's chrome for `role`, or null for the built-in -- the registry + // DynamicForm reads (docs/spec/forms/forms.md, "Chrome slots"). + function chrome(role) { + return slotRegistry ? slotRegistry.resolveChrome(role) : null + } + + // Assigns each of `values` to a chrome item's same-named property, only + // where the item declares it (DynamicForm.bindChrome's rule). + function bindChrome(item, values) { + if (!item) + return + for (const key in values) { + if (key in item) + item[key] = values[key] + } + } + // A plain two-hop property chain (Repeater.count/currentIndex -> // resultOk/resultText), not a function call: reading a property through // a user-defined QML function inside another binding does not reliably @@ -86,12 +112,31 @@ Frame { spacing: 8 Label { - text: (wizard.wizardSchema["w-title"] || wizard.wizardId) + visible: wizard.chrome("wizardHeader") === null + text: wizard.headerTitle + " (" + (wizard.currentIndex + 1) + " / " + wizard.steps.length + ")" font.bold: true font.pixelSize: 16 } + // A host's header chrome: `title`, `stepIndex`, `stepCount`, + // `stepTitle`. + Loader { + active: wizard.chrome("wizardHeader") !== null + visible: active + Layout.fillWidth: true + sourceComponent: wizard.chrome("wizardHeader") + onLoaded: wizard.bindChrome(item, { + title: Qt.binding(function () { return wizard.headerTitle }), + stepIndex: Qt.binding(function () { return wizard.currentIndex }), + stepCount: Qt.binding(function () { return wizard.steps.length }), + stepTitle: Qt.binding(function () { + const step = wizard.steps[wizard.currentIndex] + return step && step.title ? String(step.title) : "" + }) + }) + } + StackLayout { Layout.fillWidth: true currentIndex: wizard.currentIndex @@ -106,25 +151,49 @@ Frame { actionType: modelData.action schema: wizard.schemas[modelData.action] || ({}) controller: wizard.controller + slotRegistry: wizard.slotRegistry } } } + // A host's navigation chrome: `canBack`, `canNext`, `lastStep`, + // `stepIndex`, `stepCount`, `back()` and `next()` -- the same gates + // the built-in buttons use. + Loader { + active: wizard.chrome("wizardNav") !== null + visible: active + Layout.fillWidth: true + sourceComponent: wizard.chrome("wizardNav") + onLoaded: wizard.bindChrome(item, { + canBack: Qt.binding(function () { return wizard.canGoBack }), + canNext: Qt.binding(function () { return wizard.canGoNext }), + lastStep: Qt.binding(function () { return wizard.onLastStep }), + stepIndex: Qt.binding(function () { return wizard.currentIndex }), + stepCount: Qt.binding(function () { return wizard.steps.length }), + back: function () { wizard.goBack() }, + next: function () { + if (wizard.canGoNext) + wizard.goNext() + } + }) + } + RowLayout { + visible: wizard.chrome("wizardNav") === null Button { objectName: "wizardBack" text: "Back" - enabled: wizard.currentIndex > 0 + enabled: wizard.canGoBack onClicked: wizard.goBack() } Button { objectName: "wizardNext" text: "Next" - enabled: wizard.currentIndex < wizard.steps.length - 1 && wizard.currentStepDone + enabled: wizard.canGoNext onClicked: wizard.goNext() } Label { - visible: wizard.currentIndex >= wizard.steps.length - 1 + visible: wizard.onLastStep text: "Last step — fill it in to finish" opacity: 0.6 font.italic: true diff --git a/src/qt/forms/tests/tst_ViewChrome.qml b/src/qt/forms/tests/tst_ViewChrome.qml new file mode 100644 index 000000000..dc5855b20 --- /dev/null +++ b/src/qt/forms/tests/tst_ViewChrome.qml @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Chrome slots for CollectionView and WizardView: the same byChrome registry +// DynamicForm reads, with roles of their own, and the registry handed on to +// every DynamicForm they embed -- so a screen built from a host's UI kit shows +// no built-in Label, Button or Dialog anywhere, the editors included. +// +// Each case asserts what the chrome was handed, that the built-in it replaces +// is gone, and that driving the chrome drives the view (the same controller +// calls the built-in buttons make). + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtTest +import MorphForms + +TestCase { + id: testCase + name: "ViewChrome" + visible: true + width: 800 + height: 600 + + QtObject { + id: mockController + signal replyReceived(string actionType, bool ok, string payload) + property var calls: [] + property var resolvedValues: ({}) + function submitIfValid(actionType, bodyJson) { + calls.push(actionType + " " + bodyJson) + if (actionType === "ListRows") { + replyReceived(actionType, true, JSON.stringify({ rows: [ + { id: 1, name: "First", amount: { num: 15, den: 10, dp: 1 } }, + { id: 2, name: "Second", amount: { num: 30, den: 10, dp: 1 } } + ] })) + return + } + if (actionType === "WizStepOne") + resolvedValues["WizStepOne.id"] = "1" + replyReceived(actionType, true, '{"ok":true}') + } + function resolvedValue(path) { return resolvedValues[path] !== undefined ? resolvedValues[path] : "" } + } + + property var testView: ({ + "v-kind": "collection", "v-title": "Rows", "v-query": "ListRows", "v-rowKey": "id", + "v-columns": [ + { field: "id", label: "ID", "v-hidden": true }, + { field: "name", label: "Name" }, + { field: "amount", label: "Amount", "x-decimalPlaces": 1, ExtUnits: { unitAscii: "kg", unitUnicode: "kg" } } + ], + "v-rowAction": { action: "EditRow", bind: { id: "id" } }, + "v-actions": [ + { action: "DeleteRow", label: "Delete", scope: "row", bind: { id: "id" }, confirm: true }, + { action: "CreateRow", label: "New", scope: "collection" } + ] + }) + + property var testSchemas: ({ + EditRow: { properties: { id: { type: "integer", "x-order": 0 }, name: { type: "string", "x-order": 1 } }, + required: ["id", "name"] }, + WizStepOne: { properties: { label: { type: "string", "x-order": 0 } }, required: ["label"] }, + WizStepTwo: { properties: { refId: { type: "integer", "x-order": 0 } }, required: ["refId"] } + }) + + property var testWizard: ({ + "w-title": "Test flow", + "w-steps": [ { action: "WizStepOne", title: "One" }, + { action: "WizStepTwo", title: "Two", prefill: { refId: "WizStepOne.id" } } ] + }) + + // ── chrome ─────────────────────────────────────────────────────────────── + + Component { id: headerChrome; Item { objectName: "headerChrome"; property string title; property var columns; property var actions; property var fire } } + Component { + id: rowChrome + Item { + objectName: "rowChrome_" + rowKey + property var row; property var columns; property var cells; property string rowKey + property var actions; property bool canOpen; property var open; property var fire + } + } + Component { id: confirmChrome; Item { objectName: "confirmChrome"; property string message; property var action; property var row; property var accept; property var reject } } + Component { + id: editorChrome + ColumnLayout { + objectName: "editorChrome" + property string title; property bool open; property var close + property alias contentItem: body + ColumnLayout { id: body; objectName: "editorBody" } + } + } + Component { id: wizardHeaderChrome; Item { objectName: "wizardHeaderChrome"; property string title; property int stepIndex; property int stepCount; property string stepTitle } } + Component { id: wizardNavChrome; Item { objectName: "wizardNavChrome"; property bool canBack; property bool canNext; property bool lastStep; property var back; property var next } } + Component { id: emptyChrome; Item {} } + Component { id: fieldLabelChrome; Item { objectName: "fieldLabelChrome"; property string text } } + + Component { id: registryComponent; SlotRegistry {} } + + Component { + id: collectionComponent + CollectionView { viewId: "V"; view: testCase.testView; schemas: testCase.testSchemas; controller: mockController } + } + + Component { + id: wizardComponent + WizardView { wizardId: "W"; wizardSchema: testCase.testWizard; schemas: testCase.testSchemas; controller: mockController } + } + + function registryWith(chromes) { + const registry = createTemporaryObject(registryComponent, testCase) + for (const role in chromes) + registry.byChrome(role, chromes[role]) + return registry + } + + function visibleBuiltins(item, out) { + if (!item || item.visible === false) + return out + if (item instanceof Label || item instanceof Button || item instanceof TabBar) + out.push(item) + const kids = item.children || [] + for (let i = 0; i < kids.length; ++i) + visibleBuiltins(kids[i], out) + return out + } + + // ── CollectionView ─────────────────────────────────────────────────────── + + function test_collection_header_and_rows_go_through_chrome() { + mockController.calls = [] + const view = createTemporaryObject(collectionComponent, testCase, + { slotRegistry: registryWith({ collectionHeader: headerChrome, collectionRow: rowChrome }) }) + const header = findChild(view, "headerChrome") + compare(header.title, "Rows") + compare(header.columns.length, 2) + compare(header.actions[0].action, "CreateRow") + const row = findChild(view, "rowChrome_1") + verify(row !== null) + compare(row.cells[1], "1.5 kg") + compare(row.canOpen, true) + compare(row.actions[0].action, "DeleteRow") + // Built-ins are gone: no cell labels, no Open buttons. + compare(findChild(view, "cell_amount_1"), null) + compare(findChild(view, "rowOpen_1"), null) + header.fire(header.actions[0]) + verify(mockController.calls.indexOf("CreateRow {}") !== -1) + } + + function test_confirm_chrome_holds_the_action_until_accepted() { + mockController.calls = [] + const view = createTemporaryObject(collectionComponent, testCase, + { slotRegistry: registryWith({ collectionRow: rowChrome, confirmDialog: confirmChrome }) }) + const row = findChild(view, "rowChrome_2") + row.fire(row.actions[0]) + const confirm = findChild(view, "confirmChrome") + verify(confirm !== null) + compare(confirm.message, "Are you sure?") + compare(confirm.action.action, "DeleteRow") + verify(mockController.calls.every(function (c) { return c.indexOf("DeleteRow") !== 0 })) + confirm.accept() + verify(mockController.calls.indexOf('DeleteRow {"id":2}') !== -1) + // An unloaded chrome is destroyed on the next event-loop turn. + tryVerify(function () { return findChild(view, "confirmChrome") === null }) + verify(!findChild(view, "confirmDialog").visible) + } + + function test_confirm_chrome_reject_fires_nothing() { + mockController.calls = [] + const view = createTemporaryObject(collectionComponent, testCase, + { slotRegistry: registryWith({ collectionRow: rowChrome, confirmDialog: confirmChrome }) }) + const row = findChild(view, "rowChrome_1") + row.fire(row.actions[0]) + findChild(view, "confirmChrome").reject() + verify(mockController.calls.every(function (c) { return c.indexOf("DeleteRow") !== 0 })) + // An unloaded chrome is destroyed on the next event-loop turn. + tryVerify(function () { return findChild(view, "confirmChrome") === null }) + } + + function test_editor_chrome_hosts_the_prefilled_editor() { + const view = createTemporaryObject(collectionComponent, testCase, + { slotRegistry: registryWith({ collectionRow: rowChrome, editorDialog: editorChrome }) }) + const editor = findChild(view, "editorChrome") + verify(editor !== null) + compare(editor.open, false) + findChild(view, "rowChrome_2").open() + compare(editor.open, true) + compare(editor.title, "EditRow") + // The editor form lives inside the chrome, prefilled from the row. + const idField = findChild(findChild(editor, "editorBody"), "field_id") + verify(idField !== null) + compare(idField.text, "2") + verify(!findChild(view, "editorDialog").visible) + editor.close() + compare(editor.open, false) + } + + function test_the_registry_reaches_the_embedded_editor_forms() { + const view = createTemporaryObject(collectionComponent, testCase, + { slotRegistry: registryWith({ fieldLabel: fieldLabelChrome, editorDialog: editorChrome }) }) + verify(findChild(findChild(view, "editorBody"), "fieldLabelChrome") !== null) + } + + function test_a_fully_chromed_collection_shows_no_built_in() { + const view = createTemporaryObject(collectionComponent, testCase, { + slotRegistry: registryWith({ collectionHeader: headerChrome, collectionRow: rowChrome, + confirmDialog: confirmChrome, editorDialog: editorChrome, + fieldLabel: fieldLabelChrome, header: emptyChrome, status: emptyChrome, + preview: emptyChrome, result: emptyChrome }) + }) + findChild(view, "rowChrome_1").open() + const left = visibleBuiltins(view, []) + compare(left.length, 0, "still built-in: " + left.map(function (i) { return i.toString() }).join(", ")) + } + + // ── WizardView ─────────────────────────────────────────────────────────── + + function test_wizard_header_and_nav_go_through_chrome() { + const wizard = createTemporaryObject(wizardComponent, testCase, + { slotRegistry: registryWith({ wizardHeader: wizardHeaderChrome, wizardNav: wizardNavChrome }) }) + const header = findChild(wizard, "wizardHeaderChrome") + const nav = findChild(wizard, "wizardNavChrome") + compare(header.title, "Test flow") + compare(header.stepCount, 2) + compare(header.stepTitle, "One") + compare(nav.canBack, false) + compare(nav.canNext, false) + verify(!findChild(wizard, "wizardNext").visible) + // Next is gated exactly as the built-in button is: nothing happens + // until the step's action has replied ok. + nav.next() + compare(wizard.currentIndex, 0) + findChild(wizard.currentForm(), "field_label").text = "x" + tryCompare(nav, "canNext", true) + nav.next() + compare(wizard.currentIndex, 1) + compare(header.stepIndex, 1) + compare(header.stepTitle, "Two") + compare(nav.lastStep, true) + compare(nav.canBack, true) + nav.back() + compare(wizard.currentIndex, 0) + } + + function test_the_registry_reaches_every_wizard_step() { + const wizard = createTemporaryObject(wizardComponent, testCase, + { slotRegistry: registryWith({ fieldLabel: fieldLabelChrome }) }) + verify(findChild(wizard.currentForm(), "fieldLabelChrome") !== null) + } + + // ── defaults unchanged ─────────────────────────────────────────────────── + + function test_without_a_registry_the_built_ins_are_drawn() { + const view = createTemporaryObject(collectionComponent, testCase) + verify(findChild(view, "cell_amount_1") !== null) + verify(findChild(view, "rowOpen_1") !== null) + const wizard = createTemporaryObject(wizardComponent, testCase) + verify(findChild(wizard, "wizardNext").visible) + } +}