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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,29 @@ 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`,
`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_<name>` 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
Expand Down
66 changes: 66 additions & 0 deletions docs/spec/forms/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -1520,6 +1520,72 @@ 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` |

`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
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_<name>` `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))
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
Loading
Loading