diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index f4622275e..3bf6c6a06 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -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 @@ -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. | @@ -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_` `objectName` stays unique: prefill, diff --git a/src/qt/forms/qml/DynamicForm.qml b/src/qt/forms/qml/DynamicForm.qml index 54bcbf6ba..2266d2eab 100644 --- a/src/qt/forms/qml/DynamicForm.qml +++ b/src/qt/forms/qml/DynamicForm.qml @@ -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) @@ -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 @@ -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 @@ -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 }) } } } @@ -2652,8 +2671,10 @@ Frame { GridLayout { id: chromeGrid + objectName: "chromeFieldGrid" property var gridFields: [] Layout.fillWidth: true + columns: form.gridColumns Repeater { model: chromeGrid.gridFields @@ -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) }) } @@ -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 : [] @@ -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 : [] diff --git a/src/qt/forms/tests/tst_DynamicFormGridColumns.qml b/src/qt/forms/tests/tst_DynamicFormGridColumns.qml new file mode 100644 index 000000000..053b4f0fb --- /dev/null +++ b/src/qt/forms/tests/tst_DynamicFormGridColumns.qml @@ -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) + } +}