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
47 changes: 46 additions & 1 deletion docs/spec/forms/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,51 @@ encodes to a genuine empty array `[]`, not `null`; a `required` array field
is satisfied by engagement (non-blank text), not by having at least one
surviving entry.

### Boolean fields — `type: "boolean"`

glaze emits `{"type": "boolean"}` for a `bool` member, and
`{"type": ["boolean", "null"]}` for a `std::optional<bool>`. The shipped
`DynamicForm.qml` renderer gives both a `CheckBox` (`objectName: "field_" +
name`, mutually exclusive per field with the scalar and array controls, so
exactly one claims that name) rather than letting them fall through to the
plain-text control. The fallback there (`JSON.stringify(text)`) wrapped the
value as a JSON *string* — `{"flag":"true"}` — and, because a `TextField`
applies no validation of its own, accepted literally any text, so
`{"flag":"banana"}` was submitted just as readily. glaze rejects both with
`expected_true_or_false`; it does not coerce.

The control emits a bare `true` or `false`, never quoted. A `bool` member is
**required** (it has no null branch), and a checkbox always displays a definite
state, so a required boolean with no retained value is seeded `false` at
delegate creation rather than left blank — otherwise the form would show an
unchecked box while the required-field gate silently withheld submission, with
nothing on screen indicating what was missing. An *optional* boolean is left
unseeded and is omitted from the request body until the user touches it, which
is what distinguishes "not answered" from an explicit `false` for a
`std::optional<bool>` member.

### Nullable fields whose type is a `$ref` — `anyOf`

A nullable member whose underlying type is emitted as a definition rather than
inline — `std::optional<std::int64_t>`, or a `std::optional<T>` over a strong id
— produces neither a `type` key nor a top-level `$ref`:

```json
"optI64": {"anyOf": [{"$ref": "#/$defs/int64_t"}, {"type": "null"}]}
```

A renderer that resolves only a *top-level* `$ref` sees no type at all here, so
every field-kind flag is false and the value takes the plain-text path — a
quoted string the server rejects with `parse_number_failure`. `DynamicForm.qml`
therefore resolves through `anyOf` (and `oneOf`, which glaze does not currently
emit but a hand-written or evolved schema may): it takes the first branch whose
type is not `"null"`, follows a `$ref` inside it, and merges the result under
the property's own keys, so the field is typed by `T` and picks up `T`'s
constraints such as `minimum`/`maximum`. Integers on this path are emitted as
bare, exact numbers — the payload is assembled as JSON *text*, never round-tripped
through `JSON.parse`, so values beyond 2^53 (including `INT64_MAX`) survive
intact.

### Versioning stance

The emitted schema is **unversioned**. There is no `$id`, `$schema` version
Expand All @@ -665,7 +710,7 @@ renderer for it, Qt/QML, as a reusable component rather than example code.
- **`src/qt/forms`** builds the QML module `MorphForms` (CMake target
`morph_forms_module`, `qt_add_qml_module(... URI MorphForms VERSION 1.0)`):
`DynamicForm.qml` (the `Repeater`-over-`fields` form renderer: `$ref`
resolution/dual-read, the exact rational digit arithmetic, the unit
and `anyOf` resolution/dual-read, the exact rational digit arithmetic, the unit
selector, the required-field submit gate, the options-fetch, layout/
grouping into sections/tabs, the widget-hint controls — textarea, slider,
radio group — the comma-separated-with-validation `"array"`-typed field
Expand Down
102 changes: 99 additions & 3 deletions src/qt/forms/qml/DynamicForm.qml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Frame {
}

// Follow a $ref into $defs; attributes on the field win over the def's.
function resolveProp(prop) {
function resolveRef(prop) {
if (prop && prop["$ref"] !== undefined) {
const defName = prop["$ref"].split("/").pop()
const def = opt((schema["$defs"] || {})[defName], {})
Expand All @@ -108,6 +108,37 @@ Frame {
return opt(prop, {})
}

function resolveProp(prop) {
const p = resolveRef(prop)
// A nullable member whose type is itself a $ref (e.g. a bare
// std::optional<std::int64_t>, or std::optional<TagId>) emits
// {"anyOf": [{"$ref": ...}, {"type": "null"}]} with **no top-level
// "type" key**. Resolving only the top-level $ref left every kind flag
// below false, so the value fell through to the plain-text encoding and
// went out as a quoted JSON *string* that the server then rejected with
// parse_number_failure (morph#189). Resolve through the non-null branch
// so the field is typed by T. `oneOf` is handled the same way; glaze
// does not emit it today, but a hand-written or evolved schema may.
const branches = Array.isArray(p.anyOf) ? p.anyOf : (Array.isArray(p.oneOf) ? p.oneOf : null)
if (branches !== null) {
for (let i = 0; i < branches.length; ++i) {
const branch = resolveRef(branches[i])
if (branch.type === "null")
continue
// Outer keys win over the branch's (an x-* extension declared
// beside the anyOf is the more specific statement), except that
// the outer object is precisely the one with no "type".
const merged = Object.assign({}, branch, p)
delete merged.anyOf
delete merged.oneOf
if (p.type === undefined)
merged.type = branch.type
return merged
}
}
return p
}

// --- i18n: field-slot key derivation and resolution ------------------
// Mirrors morph::forms::i18n::fieldKey/explicitFieldKey (forms/i18n.hpp)
// and morph::render::resolveText (render/i18n.hpp) for the one slot this
Expand Down Expand Up @@ -213,6 +244,12 @@ Frame {
isQuantity: dp !== undefined,
decimals: opt(dp, 0),
isInteger: types.indexOf("integer") !== -1,
// "boolean" -- a CheckBox, not the plain text field's
// fall-through (which wrapped the typed text as a JSON
// *string*: {"flag":"true"}, or {"flag":"banana"} for
// anything at all, since a TextField applies no validation.
// glaze rejects both with expected_true_or_false).
isBoolean: types.indexOf("boolean") !== -1,
// "array" (glaze's std::vector<T> schema shape: {"type":
// "array", "items": {...}}) -- a comma-separated-with-
// validation control, not the plain text field's
Expand Down Expand Up @@ -690,6 +727,16 @@ Frame {
// Normalise "007" -> "7": JSON forbids leading zeros in numbers.
return text.replace(/^(-?)0+(?=\d)/, "$1")
}
if (f.isBoolean) {
// Emitted bare, never quoted. The CheckBox only ever stores these
// two spellings; any other retained value (a prefill from a stale
// payload, say) is invalid rather than silently coerced.
if (text === "true")
return "true"
if (text === "false")
return "false"
return null
}
return JSON.stringify(text)
}

Expand Down Expand Up @@ -1036,11 +1083,12 @@ Frame {

TextField {
id: entry
objectName: fieldColumn.modelData.isArray ? "" : "field_" + fieldColumn.modelData.name
objectName: (fieldColumn.modelData.isArray || fieldColumn.modelData.isBoolean)
? "" : "field_" + fieldColumn.modelData.name
visible: overrideLoader.sourceComponent === null
&& !fieldColumn.modelData.isChoice && !fieldColumn.modelData.isDateTime
&& !fieldColumn.modelData.isMultiline && !fieldColumn.modelData.isSlider
&& !fieldColumn.modelData.isArray
&& !fieldColumn.modelData.isArray && !fieldColumn.modelData.isBoolean
Layout.fillWidth: true
readOnly: fieldColumn.modelData.readOnly
placeholderText: fieldColumn.modelData.placeholder !== ""
Expand Down Expand Up @@ -1101,6 +1149,54 @@ Frame {
+ " Comma-separated list."
}

// "boolean" — a CheckBox. The plain TextField's fall-through
// encoded the typed text as a JSON *string* ({"flag":"true"}),
// and applied no validation at all, so "banana" was accepted
// and sent; glaze rejected both with expected_true_or_false
// (morph#189). A CheckBox can only produce the two valid
// spellings. Reuses the plain TextField's field_ objectName —
// the two are mutually exclusive per field (isBoolean), so
// exactly one claims it.
CheckBox {
id: boolEntry
objectName: fieldColumn.modelData.isBoolean ? "field_" + fieldColumn.modelData.name : ""
visible: overrideLoader.sourceComponent === null && fieldColumn.modelData.isBoolean
enabled: !fieldColumn.modelData.readOnly
onToggled: form.setFieldValue(fieldColumn.modelData.name, checked ? "true" : "false")
// Re-seed from the retained value whenever this delegate is
// (re)created — see the plain TextField's comment above for
// why (a tab switch destroys and rebuilds every control).
//
// A *required* boolean with no retained value is seeded
// "false" rather than left blank: a checkbox always shows a
// definite state, so an unchecked required box that blocked
// `ready` would be a form the user cannot see how to
// satisfy. An *optional* boolean is left unset and is
// omitted from the payload until the user touches it, which
// is what distinguishes "not answered" from an explicit
// false for a std::optional<bool> member.
Component.onCompleted: form.withoutAutoSubmit(function() {
// Every field's delegate instantiates this CheckBox and
// hides it unless the field is boolean, so this hook runs
// for fields of every type -- without this guard it seeded
// "false" into every *required* field, satisfying the
// required gate for text fields the user had not filled in.
if (!fieldColumn.modelData.isBoolean)
return
const retained = form.opt(form.fieldValues[fieldColumn.modelData.name], "")
if (retained === "" && fieldColumn.modelData.required) {
form.setFieldValue(fieldColumn.modelData.name, "false")
boolEntry.checked = false
return
}
boolEntry.checked = retained === "true"
})
Accessible.role: Accessible.CheckBox
Accessible.name: fieldColumn.modelData.name
Accessible.description: (fieldColumn.modelData.required ? "Required. " : "")
+ fieldColumn.modelData.description
}

// x-widget: "textarea" (a Multiline field) — same wire string
// as an ordinary TextField, just edited over multiple lines.
TextArea {
Expand Down
Loading
Loading