diff --git a/CHANGELOG.md b/CHANGELOG.md index 159cf94e0..79f69d28e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,18 @@ API surface). ### Added +- **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 7ec167c8a..02357ccd2 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -1448,6 +1448,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 04b1587b6..bcf83bd2e 100644 --- a/src/qt/forms/qml/SlotRegistry.qml +++ b/src/qt/forms/qml/SlotRegistry.qml @@ -64,10 +64,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) + } +}