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
27 changes: 24 additions & 3 deletions docs/spec/forms/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,27 @@ bar (`renderRuns`), and lays each section's fields out in a 2-column grid
honoring `x-colspan` — falling back to a single implicit flat section
(one column, no chrome) when the schema carries no `x-layout` at all.

**A host chooses the grid: `gridColumns`.** `DynamicForm.gridColumns` (default
`2`) is the column count of every field grid the form builds: each section's,
each tab set's, and the one it creates inside a host's section or tab-set
[chrome](#chrome-slots). `flatGridColumns` is the implicit flat bucket's — the
whole form without `x-layout`, and the trailing group of fields no group names.
It is `1` at the default, the pre-grouping renderer's single column, and
follows `gridColumns` as soon as a host sets that to anything else, so one
property puts the whole form on the host's grid:

```qml
DynamicForm { gridColumns: 12 } // x-colspan 6 = half a row, 4 = a third, 12 = full
```

A field spans `x-colspan` columns (1 when absent), **clamped to its grid's
column count**, so a span wider than the grid takes one full row rather than
widening the grid. Both properties are bindings; changing one relays the form.
With both left at their defaults the layout is unchanged.
`src/qt/forms/tests/tst_DynamicFormGridColumns.qml` pins it (9 cases); fixing
every grid back at 2/1 columns reddens 5 of them, and dropping the clamp another
5.

**Tab switching destroys and rebuilds controls, so they re-seed from
`fieldValues`.** The tab bar drives its `Repeater` off
`sections[currentTab].fields`, so leaving a tab destroys that tab's field
Expand Down Expand Up @@ -838,7 +859,7 @@ below) `DynamicForm.qml`'s `resolveProp` does exactly this dual read.
| `x-layout` | top-level (object) | object | The form's group structure: `{ "groups": [ { "title": string, "kind": "section"\|"tab"\|"accordion", "fields": [wire-key,…] }, … ] }`, in `A::formLayout` declaration order. Emitted only when the action declares `formLayout`. The renderer builds the named containers in array order and places each field in its group; fields absent from every group go in a trailing default group. |
| `x-group` | property node (sibling of `$ref`) | string | The title of the group this field belongs to. Omitted for a field in the implicit default group, or when `x-layout` is absent. |
| `x-section` | property node (sibling of `$ref`) | non-negative integer | The 0-based index of this field's group in `x-layout.groups`. Omitted under the same conditions as `x-group`. |
| `x-colspan` | property node (sibling of `$ref`) | positive integer | Number of grid columns the field should span, from `FieldSpan::colspan`. Emitted only when greater than `1` (the default, single-column width). A renderer laying fields out in a grid widens the control; a single-column renderer ignores it. |
| `x-colspan` | property node (sibling of `$ref`) | positive integer | Number of grid columns the field should span, from `FieldSpan::colspan`. Emitted only when greater than `1` (the default, single-column width). A renderer laying fields out in a grid widens the control; a single-column renderer ignores it. `DynamicForm` clamps it to the grid's column count (`gridColumns`, see [Layout & grouping](#layout--grouping--sections-tabs-spans)). |
| `x-rules` | top-level (object) | array of rule objects | Cross-field rules the renderer must satisfy before enabling submit, and should surface live as inline errors. Emitted only when the action declares `formRules`; absent otherwise. A renderer that ignores it falls back to per-field `required` only. |
| ↳ `kind` | rule / condition object | string | One of the closed vocabulary ids in the "Cross-field rules" section's table above (or a condition id: `engaged`, `notEngaged`, `equals`, `and`, `or`, `not`). An unrecognised `kind` — a rule *or* a nested condition — must be treated as "cannot evaluate": a third answer, distinct from both true and false. The renderer neither claims the rule is satisfied nor blocks submission on it; the payload reaches the server, which runs the compiled rule list and has no unrecognised-kind case. See [Renderer fallback](#renderer-fallback) for the full contract and why it is *defer*, not *block*. |
| ↳ `fields` | rule / condition object | array of strings | Wire field names the rule ranges over, in declaration order (operand order is significant for `greater`/`less`). Absent on `and`/`or`/`not`, which range over nested conditions (`conditions`/`condition` below) instead of fields directly. |
Expand Down Expand Up @@ -1670,8 +1691,8 @@ 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
`section`, `accordion` and `tabset`, the form creates its field grid
(`gridColumns` 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,
Expand Down
38 changes: 30 additions & 8 deletions src/qt/forms/qml/DynamicForm.qml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,18 @@ Frame {
// "no catalog installed" — every label/help/placeholder falls back to
// its schema literal, exactly as today.
property var catalog: null
// Columns of every field grid the form builds -- a section's, a tab
// set's, and the one created inside a host's section/tab-set chrome. A
// field spans `x-colspan` of them, clamped to the grid's columns. A host
// laying fields out on its own grid (say 12 columns) sets this once.
property int gridColumns: 2

// Columns of the implicit flat bucket -- the whole form when the schema
// declares no x-layout, and the trailing group of fields no group names.
// One at the default, which is the pre-grouping renderer's single column;
// follows `gridColumns` once a host chooses a grid of its own.
property int flatGridColumns: gridColumns === 2 ? 1 : gridColumns

property string displayLocale: "C"
property var qtLocale: Qt.locale(displayLocale)

Expand Down Expand Up @@ -2063,7 +2075,13 @@ Frame {
objectName: "column_" + fieldColumn.modelData.name
required property var modelData
Layout.fillWidth: true
Layout.columnSpan: fieldColumn.modelData.colspan
// Clamped to the columns of the grid this delegate sits in, so an
// x-colspan wider than the grid fills one row instead of widening it.
Layout.columnSpan: {
const gridColumnCount = (fieldColumn.parent && fieldColumn.parent.columns > 0)
? fieldColumn.parent.columns : fieldColumn.modelData.colspan
return Math.max(1, Math.min(fieldColumn.modelData.colspan, gridColumnCount))
}
visible: { form.rulesRevision; return !fieldColumn.modelData.hidden && form.fieldVisible(fieldColumn.modelData.name) }
enabled: { form.rulesRevision; return !form.fieldReadonly(fieldColumn.modelData.name) }
spacing: 2
Expand Down Expand Up @@ -2611,9 +2629,10 @@ Frame {
}

GridLayout {
objectName: "sectionGrid"
Layout.fillWidth: true
visible: box.sectionChrome === null && !box.collapsed
columns: box.runData.section.kind === "flat" ? 1 : 2
columns: box.runData.section.kind === "flat" ? form.flatGridColumns : form.gridColumns

// Empty under a chrome: the fields are created inside it
// instead, and one delegate per field is what keeps every
Expand All @@ -2639,7 +2658,7 @@ Frame {
kind: box.runData.section.kind,
section: box.runData.section
})
form.createFieldGrid(item, 2, function () { return box.runData.section.fields })
form.createFieldGrid(item, function () { return box.runData.section.fields })
}
}
}
Expand All @@ -2652,8 +2671,10 @@ Frame {

GridLayout {
id: chromeGrid
objectName: "chromeFieldGrid"
property var gridFields: []
Layout.fillWidth: true
columns: form.gridColumns

Repeater {
model: chromeGrid.gridFields
Expand All @@ -2663,14 +2684,14 @@ Frame {
}

// `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) {
// chrome's currentIndex) rather than a snapshot. The grid has
// `gridColumns` columns, like the built-in section and tab-set grids.
function createFieldGrid(chromeItem, 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)
})
}
Expand Down Expand Up @@ -2707,9 +2728,10 @@ Frame {
}

GridLayout {
objectName: "tabGrid"
Layout.fillWidth: true
visible: tabsBox.tabsetChrome === null
columns: 2
columns: form.gridColumns

Repeater {
model: tabsBox.tabsetChrome === null ? tabsBox.runData.sections[tabsBox.currentTab].fields : []
Expand All @@ -2731,7 +2753,7 @@ Frame {
form.bindChrome(chromeItem, {
tabs: tabsBox.runData.sections.map(function (section) { return { title: section.title } })
})
form.createFieldGrid(chromeItem, 2, function () {
form.createFieldGrid(chromeItem, function () {
const index = ("currentIndex" in chromeItem) ? chromeItem.currentIndex : 0
const section = tabsBox.runData.sections[index]
return section ? section.fields : []
Expand Down
195 changes: 195 additions & 0 deletions src/qt/forms/tests/tst_DynamicFormGridColumns.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: Apache-2.0
//
// DynamicForm.gridColumns: the number of columns of every field grid the form
// builds, so a host can lay fields out on a grid of its own (12 columns, say)
// with x-colspan. The default keeps the renderer's own layout -- two columns
// per section and tab set, one for the implicit flat bucket -- and a span is
// clamped to the grid it sits in. Asserted on the grids' `columns`, the
// delegates' spans, and where the fields actually land.

pragma ComponentBehavior: Bound

import QtQuick
import QtQuick.Layouts
import QtTest
import MorphForms

TestCase {
id: testCase
name: "DynamicFormGridColumns"
visible: true
width: 800
height: 900
when: windowShown

// No x-layout: one implicit flat bucket.
property var flatSchema: ({
properties: {
a: { type: ["integer", "null"], "x-order": 0, title: "A", "x-colspan": 6 },
b: { type: ["integer", "null"], "x-order": 1, title: "B", "x-colspan": 6 },
c: { type: ["integer", "null"], "x-order": 2, title: "C", "x-colspan": 20 },
d: { type: ["integer", "null"], "x-order": 3, title: "D" }
},
required: []
})

property var layoutSchema: ({
properties: {
a: { type: ["integer", "null"], "x-order": 0, "x-section": 0, "x-colspan": 4 },
b: { type: ["integer", "null"], "x-order": 1, "x-section": 0, "x-colspan": 8 },
c: { type: ["integer", "null"], "x-order": 2, "x-section": 1, "x-colspan": 3 },
d: { type: ["integer", "null"], "x-order": 3, "x-section": 2, "x-colspan": 2 },
e: { type: ["integer", "null"], "x-order": 4 }
},
required: [],
"x-layout": {
groups: [
{ title: "Identity", kind: "section", fields: ["a", "b"] },
{ title: "One", kind: "tab", fields: ["c"] },
{ title: "Two", kind: "tab", fields: ["d"] }
]
}
})

Component {
id: flatForm
DynamicForm { width: 780; actionType: "T_Flat"; schema: testCase.flatSchema; controller: null }
}

Component {
id: layoutForm
DynamicForm { width: 780; actionType: "T_Layout"; schema: testCase.layoutSchema; controller: null }
}

Component {
id: registryComponent
SlotRegistry {}
}

Component {
id: sectionChrome
ColumnLayout {
objectName: "sectionChrome"
property string title
property string kind
property var section
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; Layout.fillWidth: true }
}
}

function column(form, name) {
const item = findChild(form, "column_" + name)
verify(item !== null, "no delegate for " + name)
return item
}

function gridOf(form, name) {
return column(form, name).parent
}

// ── the default: unchanged ───────────────────────────────────────────────

function test_the_default_keeps_one_flat_column_and_two_per_section() {
const flat = createTemporaryObject(flatForm, testCase)
compare(flat.gridColumns, 2)
compare(flat.flatGridColumns, 1)
compare(gridOf(flat, "a").columns, 1)

const sectioned = createTemporaryObject(layoutForm, testCase)
compare(gridOf(sectioned, "a").objectName, "sectionGrid")
compare(gridOf(sectioned, "a").columns, 2)
compare(gridOf(sectioned, "c").objectName, "tabGrid")
compare(gridOf(sectioned, "c").columns, 2)
// The trailing group of unnamed fields is the flat bucket.
compare(gridOf(sectioned, "e").columns, 1)
}

function test_a_span_is_clamped_to_the_grid() {
const sectioned = createTemporaryObject(layoutForm, testCase)
compare(column(sectioned, "a").Layout.columnSpan, 2)
compare(column(sectioned, "b").Layout.columnSpan, 2)
const flat = createTemporaryObject(flatForm, testCase)
compare(column(flat, "a").Layout.columnSpan, 1)
compare(column(flat, "d").Layout.columnSpan, 1)
}

// ── a host's grid ────────────────────────────────────────────────────────

function test_a_host_grid_applies_to_the_flat_form() {
const form = createTemporaryObject(flatForm, testCase, { gridColumns: 12 })
compare(form.flatGridColumns, 12)
compare(gridOf(form, "a").columns, 12)
compare(column(form, "a").Layout.columnSpan, 6)
compare(column(form, "c").Layout.columnSpan, 12) // 20, clamped
compare(column(form, "d").Layout.columnSpan, 1)
// Two half-width fields share a row; the clamped one takes the next.
tryVerify(function () { return column(form, "b").x > column(form, "a").x })
compare(column(form, "a").y, column(form, "b").y)
verify(column(form, "c").y > column(form, "a").y)
verify(column(form, "c").width > column(form, "a").width)
}

function test_a_host_grid_applies_to_sections_and_tab_sets() {
const form = createTemporaryObject(layoutForm, testCase, { gridColumns: 12 })
compare(gridOf(form, "a").columns, 12)
compare(column(form, "a").Layout.columnSpan, 4)
compare(column(form, "b").Layout.columnSpan, 8)
compare(gridOf(form, "c").columns, 12)
compare(column(form, "c").Layout.columnSpan, 3)
compare(gridOf(form, "e").columns, 12)
tryVerify(function () { return column(form, "b").x > column(form, "a").x })
compare(column(form, "a").y, column(form, "b").y)
}

function test_the_flat_bucket_can_be_set_on_its_own() {
const form = createTemporaryObject(flatForm, testCase, { flatGridColumns: 2 })
compare(form.gridColumns, 2)
compare(gridOf(form, "a").columns, 2)
compare(column(form, "a").Layout.columnSpan, 2)
}

function test_changing_the_grid_relays_the_form() {
const form = createTemporaryObject(layoutForm, testCase)
compare(column(form, "b").Layout.columnSpan, 2)
form.gridColumns = 12
compare(gridOf(form, "a").columns, 12)
compare(column(form, "b").Layout.columnSpan, 8)
}

// ── grids created inside a host's chrome ─────────────────────────────────

function test_a_host_grid_applies_inside_section_and_tabset_chrome() {
const registry = createTemporaryObject(registryComponent, testCase)
registry.byChrome("section", sectionChrome)
registry.byChrome("tabset", tabsetChrome)
const form = createTemporaryObject(layoutForm, testCase, { slotRegistry: registry, gridColumns: 12 })
verify(findChild(form, "sectionChrome") !== null)
compare(gridOf(form, "a").objectName, "chromeFieldGrid")
compare(gridOf(form, "a").columns, 12)
compare(column(form, "b").Layout.columnSpan, 8)
compare(gridOf(form, "c").objectName, "chromeFieldGrid")
compare(gridOf(form, "c").columns, 12)
compare(column(form, "c").Layout.columnSpan, 3)
}

function test_the_default_grid_inside_chrome_is_two_columns() {
const registry = createTemporaryObject(registryComponent, testCase)
registry.byChrome("section", sectionChrome)
const form = createTemporaryObject(layoutForm, testCase, { slotRegistry: registry })
compare(gridOf(form, "a").objectName, "chromeFieldGrid")
compare(gridOf(form, "a").columns, 2)
compare(column(form, "b").Layout.columnSpan, 2)
}
}
Loading