Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
9 changes: 9 additions & 0 deletions docs/spec/forms/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/spec/forms/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<V>()`
Expand Down
15 changes: 15 additions & 0 deletions docs/spec/forms/workflows_navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
121 changes: 119 additions & 2 deletions src/qt/forms/qml/CollectionView.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -232,14 +260,58 @@ 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
// schema difference.
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()
Expand All @@ -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
}
}

Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down
8 changes: 5 additions & 3 deletions src/qt/forms/qml/SlotRegistry.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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++
Expand Down
Loading
Loading