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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ API surface).

### Added

- **`SlotRegistry.byKind(kind, component)` — one host control per kind of
control.** The JSON type `byType` keys on does not identify a control: a
`Quantity` and a nested object are both `"object"`, a `Choice` is
`"integer"`, an enum and a `Timestamp` are `"string"`. Field descriptors now
carry `kind` (`quantity`, `choice`, `enum`, `datetime`, `date`, `boolean`,
`integer`, `number`, `string`, `array`, `objectArray`, `object`), and
`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).

- **A locale numeric entry accepts an explicit `+`.**
`morph::render::normalizeLocaleNumber` had no notion of a positive sign: a
leading `+` fell through to the "any other character is malformed" arm, so
Expand Down
33 changes: 30 additions & 3 deletions docs/spec/forms/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -1404,9 +1404,11 @@ forking the renderer:
- **`SlotRegistry` (QML type, module `MorphForms`, entirely client-side).** A
lookup a host app populates at startup: `byField(action, field, component)`,
`byWidget(xWidget, component)`, `byUnit(unitAscii, component)`,
`byType(jsonType, component)`, and `resolve(action, field, xWidget,
unitAscii, jsonType)`, which returns the highest-priority match or `null`.
Resolution order is **field → `x-widget` → unit → type → built-in default**.
`byKind(kind, component)`, `byType(jsonType, component)`, and
`resolve(action, field, xWidget, unitAscii, jsonType, kind)`, which returns
the highest-priority match or `null` (`kind` is optional; the five-argument
call resolves as before). Resolution order is **field → `x-widget` → unit →
kind → type → built-in default**.
`DynamicForm` gains a `slotRegistry` property (`null` by default — no
behavior change for an app that never sets it); when a field resolves to a
registered `Component`, `DynamicForm` loads it via a `Loader` and hides its
Expand All @@ -1424,6 +1426,31 @@ forking the renderer:
plain objects mutated in place, which does not by itself notify a binding
that already read them.

**`byKind` — one host control per kind of control.** The JSON type does not
name the control a field needs: a `Quantity` and a nested object are both
`"object"`, a `Choice` is `"integer"`, a closed set and a `Timestamp` are
`"string"`. Every field descriptor therefore carries `kind`, the control this
renderer would draw, decided in the order its encoder is chosen:

| `kind` | Member (schema shape) |
|---|---|
| `objectArray` | `std::vector<Sub>` (`array` whose `items` are an object) |
| `array` | any other `std::vector<T>` |
| `enum` | a closed set (`oneOf` of `const`s, or `enum`) |
| `choice` | a `Choice` (`x-optionsAction`) |
| `datetime` | a `Timestamp` (`format: "date-time"`) |
| `date` | `format: "date"` (drawn as a plain text field by the built-in renderer) |
| `quantity` | a `Quantity`, or any property with `x-decimalPlaces` |
| `integer` / `boolean` / `number` | `type` of that name |
| `object` | a nested aggregate (unrepresentable without a slot) |
| `string` | everything else |

A host registers one kit component per kind (`byKind("quantity", …)`,
`byKind("choice", …)`, …); a field, `x-widget` or unit registration still wins,
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.

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
37 changes: 36 additions & 1 deletion src/qt/forms/qml/DynamicForm.qml
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,37 @@ Frame {
return ""
}

// The renderer kind of a resolved property: the control it gets, which
// the JSON type alone does not say (a Quantity and a nested object are both
// "object", a Choice is "integer", an enum and a date-time are "string").
// Asked in the order fieldJsonLiteral encodes in, so the kind names the
// encoder the field actually gets.
function fieldKind(p, types, dp, optionsAction, isEnum) {
if (types.indexOf("array") !== -1) {
const itemTypes = jsonTypes(resolveProp(p.items))
return itemTypes.indexOf("object") !== -1 ? "objectArray" : "array"
}
if (isEnum)
return "enum"
if (optionsAction !== undefined)
return "choice"
if (p.format === "date-time")
return "datetime"
if (p.format === "date")
return "date"
if (dp !== undefined)
return "quantity"
if (types.indexOf("integer") !== -1)
return "integer"
if (types.indexOf("boolean") !== -1)
return "boolean"
if (types.indexOf("number") !== -1)
return "number"
if (types.indexOf("object") !== -1)
return "object"
return "string"
}

// Value/label pairs for a property that states a **closed set of values**
// outright, or [] for one that does not. Two spellings, both handled:
//
Expand Down Expand Up @@ -609,6 +640,9 @@ Frame {
// to "" and SlotRegistry.resolve()'s byWidget tier never
// matches.
xWidget: opt(widget, ""),
// The control this renderer would draw, named for
// SlotRegistry.byKind (see fieldKind).
kind: fieldKind(p, types, dp, optionsAction, enumOptionRows.length > 0),
unitAscii: opt(extUnits.unitAscii, ""),
jsonType: types.length > 0 ? types[0] : ""
}
Expand Down Expand Up @@ -1708,7 +1742,8 @@ Frame {
? form.slotRegistry.resolve(form.actionType, fieldColumn.modelData.name,
fieldColumn.modelData.xWidget,
fieldColumn.modelData.unitAscii,
fieldColumn.modelData.jsonType)
fieldColumn.modelData.jsonType,
fieldColumn.modelData.kind)
: null

Loader {
Expand Down
26 changes: 21 additions & 5 deletions src/qt/forms/qml/SlotRegistry.qml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
// registry" / docs/planned/gui_renderer_toolkit.md). Entirely client-side: a
// slot is a QML Component the host app registers; it never appears in the
// schema or on the wire, and two renderers of the same schema may register
// different slots. DynamicForm consults byField/byWidget/byUnit/byType in
// that priority order and falls back to its own built-in control on a miss.
// different slots. DynamicForm consults byField/byWidget/byUnit/byKind/byType
// in that priority order and falls back to its own built-in control on a miss.

import QtQuick

Expand All @@ -16,6 +16,7 @@ QtObject {
property var _byField: ({}) // "action field" -> Component
property var _byWidget: ({}) // x-widget id -> Component
property var _byUnit: ({}) // unitAscii -> Component
property var _byKind: ({}) // renderer kind ("quantity", "choice", ...) -> Component
property var _byType: ({}) // JSON type ("integer", "string", ...) -> Component

// Bumped on every by*() registration. `_byField`/`_byWidget`/`_byUnit`/
Expand Down Expand Up @@ -51,22 +52,37 @@ QtObject {
revision++
}

/// Registers @p component for every field of the given renderer kind --
/// the control DynamicForm would otherwise draw, as its field
/// descriptor's `kind` names it: "quantity", "choice", "enum", "datetime",
/// "date", "boolean", "integer", "number", "string", "array",
/// "objectArray" or "object". Unlike the JSON type, a kind tells a
/// Quantity from a nested object, a Choice from an integer, and an enum or
/// a date-time from free text.
function byKind(kind, component) {
_byKind[kind] = component
revision++
}

/// Registers @p component for every field of the given JSON Schema
/// `type` (e.g. "integer", "string").
function byType(jsonType, component) {
_byType[jsonType] = component
revision++
}

/// Resolution order: field -> x-widget -> unit -> type -> null
/// Resolution order: field -> x-widget -> unit -> kind -> type -> null
/// (built-in). Returns the first matching Component, or null on a total
/// miss (DynamicForm then renders its own built-in control).
function resolve(action, field, xWidget, unitAscii, jsonType) {
/// miss (DynamicForm then renders its own built-in control). @p kind is
/// optional, so a caller passing the five original arguments resolves
/// exactly as before.
function resolve(action, field, xWidget, unitAscii, jsonType, kind) {
registry.revision
const key = action + " " + field
if (_byField[key] !== undefined) return _byField[key]
if (xWidget !== "" && _byWidget[xWidget] !== undefined) return _byWidget[xWidget]
if (unitAscii !== "" && _byUnit[unitAscii] !== undefined) return _byUnit[unitAscii]
if (kind !== undefined && kind !== "" && _byKind[kind] !== undefined) return _byKind[kind]
if (jsonType !== "" && _byType[jsonType] !== undefined) return _byType[jsonType]
return null
}
Expand Down
135 changes: 135 additions & 0 deletions src/qt/forms/tests/tst_SlotRegistryByKind.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: Apache-2.0
//
// SlotRegistry.byKind: one host control per renderer kind.
//
// The JSON type a field carries does not name the control it needs -- a
// Quantity and a nested object are both "object", a Choice is "integer", an
// enum and a date-time are "string" -- so a host registering one component per
// kind of control had to go field by field. Each case below builds the schema
// shape schemaJson<A>() emits for one member kind and asserts both halves: the
// descriptor's `kind`, and that a byKind slot (not the byType one registered
// for the same JSON type) is what the form loads.

pragma ComponentBehavior: Bound

import QtQuick
import QtTest
import MorphForms

TestCase {
id: testCase
name: "SlotRegistryByKind"
visible: true

property var kindSchema: ({
"$defs": {
"double": { type: "number" },
"Row": { type: "object", properties: { a: { type: "integer", "x-order": 0 } }, required: ["a"] },
"Sub": { type: "object", properties: { b: { type: "string", "x-order": 0 } } }
},
properties: {
mass: {
type: ["object", "null"],
properties: { num: { type: "integer" }, den: { type: "integer" }, dp: { type: "integer" } },
ExtUnits: { unitAscii: "kg", unitUnicode: "kg" }, "x-decimalPlaces": 2, "x-order": 0
},
sample: { type: "integer", "x-optionsAction": "ListSamples", "x-order": 1 },
role: { type: "string", oneOf: [{ title: "A", const: "A" }, { title: "B", const: "B" }], "x-order": 2 },
takenAt: { type: "string", format: "date-time", "x-order": 3 },
day: { type: "string", format: "date", "x-order": 4 },
done: { type: "boolean", "x-order": 5 },
count: { type: "integer", "x-order": 6 },
ratio: { "$ref": "#/$defs/double", "x-order": 7 },
note: { type: "string", "x-order": 8 },
tags: { type: "array", items: { type: "string" }, "x-order": 9 },
rows: { type: "array", items: { "$ref": "#/$defs/Row" }, "x-order": 10 },
sub: { "$ref": "#/$defs/Sub", "x-order": 11 }
},
required: []
})

readonly property var expectedKinds: ({
mass: "quantity", sample: "choice", role: "enum", takenAt: "datetime", day: "date",
done: "boolean", count: "integer", ratio: "number", note: "string", tags: "array",
rows: "objectArray", sub: "object"
})

// A slot that says which kind it was registered for.
Component {
id: kindSlot
Item {
property var field
property var setValue
property string registeredKind
objectName: "kindSlot_" + (field ? field.name : "")
}
}

Component {
id: typeSlot
Item {
property var field
property var setValue
objectName: "typeSlot_" + (field ? field.name : "")
}
}

Component {
id: registryComponent
SlotRegistry {}
}

Component {
id: formComponent
DynamicForm { actionType: "T_Kind"; controller: null; schema: testCase.kindSchema }
}

function test_every_field_names_its_kind() {
const form = createTemporaryObject(formComponent, testCase)
for (const name in expectedKinds)
compare(form.fieldByName[name].kind, expectedKinds[name], name)
}

function test_resolution_order_puts_kind_between_unit_and_type() {
const registry = createTemporaryObject(registryComponent, testCase)
registry.byType("object", typeSlot)
registry.byKind("quantity", kindSlot)
compare(registry.resolve("T", "mass", "", "", "object", "quantity"), kindSlot)
registry.byUnit("kg", typeSlot)
compare(registry.resolve("T", "mass", "", "kg", "object", "quantity"), typeSlot)
// The five-argument form still resolves as before.
compare(registry.resolve("T", "other", "", "", "object"), typeSlot)
}

function test_a_kind_slot_is_loaded_for_every_field_of_that_kind() {
const registry = createTemporaryObject(registryComponent, testCase)
// byType for each JSON type in play: a kind must beat it.
const jsonTypes = ["object", "integer", "string", "boolean", "number", "array"]
for (let t = 0; t < jsonTypes.length; ++t)
registry.byType(jsonTypes[t], typeSlot)
for (const name in expectedKinds)
registry.byKind(expectedKinds[name], kindSlot)
const form = createTemporaryObject(formComponent, testCase, { slotRegistry: registry })
for (const name in expectedKinds) {
verify(findChild(form, "kindSlot_" + name) !== null, name)
compare(findChild(form, "typeSlot_" + name), null, name)
}
}

function test_an_unregistered_kind_falls_through_to_the_type() {
const registry = createTemporaryObject(registryComponent, testCase)
registry.byType("integer", typeSlot)
registry.byKind("choice", kindSlot)
const form = createTemporaryObject(formComponent, testCase, { slotRegistry: registry })
verify(findChild(form, "kindSlot_sample") !== null)
verify(findChild(form, "typeSlot_count") !== null)
}

function test_a_kind_slot_drives_the_form() {
const registry = createTemporaryObject(registryComponent, testCase)
registry.byKind("quantity", kindSlot)
const form = createTemporaryObject(formComponent, testCase, { slotRegistry: registry })
findChild(form, "kindSlot_mass").setValue("1.25")
compare(form.previewLine, '{"mass":{"num":125,"den":100,"dp":2}}')
}
}
Loading