morph::forms::schemaJson<A>() fully describes a C++ enum class member: glaze
emits it as a closed set of const alternatives, each with its own title.
"role":{"type":"string",
"oneOf":[{"title":"Viewer","const":"Viewer"},
{"title":"Member","const":"Member"},
{"title":"Manager","const":"Manager"}],
"x-order":2,"title":"Role"}
The shipped renderer throws all of that away. DynamicForm.qml's resolveProp
(src/qt/forms/qml/DynamicForm.qml:175-203) collapses an oneOf to its first
non-null branch — a path added for the nullable-$ref shape morph#189 fixed,
where the branches differ only in nullability — and then fields
(:248-380) never looks at const or enum at all. isChoice is set solely
by the presence of x-optionsAction. So a closed enum renders as a plain
TextField and any string the user types is submitted verbatim.
Verification status: reproduced
Measured at 1f7a4619 (branch scenario-corpus-both-axes) on a
-DMORPH_BUILD_FORMS_QML=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=kanban
configure, Qt 6.11.2, QT_QPA_PLATFORM=offscreen.
A scratch tst_*.qml run through the shipped suite's own runner
(morph_forms_qml_tests -input <dir>), with two schemas pasted verbatim
from schemaJson<kanban::SetMemberRole>() / schemaJson<kanban::CreateRule>()
and bound declaratively (schema: JSON.parse(...), i.e. the same shape every
app uses):
SetMemberRole FIELD projectId jsonType="integer" isChoice=false isInteger=true optionsAction=""
SetMemberRole FIELD principal jsonType="string" isChoice=false isInteger=false optionsAction=""
SetMemberRole FIELD role jsonType="string" isChoice=false isInteger=false optionsAction=""
control drawn for `role`: TextField_QMLTYPE_36(0x55e1f8c061c0, "field_role")
SetMemberRole ready with role='Emperor': true
SetMemberRole previewLine: {"projectId":1,"principal":"bob","role":"Emperor"}
CreateRule FIELD projectId jsonType="integer" isChoice=false isInteger=true optionsAction=""
CreateRule FIELD triggerColumnId jsonType="integer" isChoice=false isInteger=true optionsAction=""
CreateRule FIELD mutationType jsonType="string" isChoice=false isInteger=false optionsAction=""
CreateRule FIELD mutationValue jsonType="string" isChoice=false isInteger=false optionsAction=""
control drawn for `mutationType`: TextField_QMLTYPE_36(0x55e1f8598770, "field_mutationType")
control drawn for `triggerColumnId`: TextField_QMLTYPE_36(0x55e1f8407ef0, "field_triggerColumnId")
CreateRule ready with mutationType='Explode': true
CreateRule previewLine: {"projectId":1,"triggerColumnId":3,"mutationType":"Explode","mutationValue":"urgent"}
Two separate consequences, both in that output:
- No affordance. The user gets a text box for a three-value set the schema
fully enumerates, with the alternatives' titles (already the human labels)
discarded.
- The client gate does not gate.
ready == true and a body is assembled
for role = "Emperor" / mutationType = "Explode". The server rejects it
(glaze's enum reader refuses an unknown name), so this is a UX defect and not
a correctness hole — but the whole point of the client-side gate is to say
"this cannot be submitted" before the round trip, and here it says the
opposite. Compare isBoolean, which exists precisely so a two-valued field
is not a text box that accepts "banana" (DynamicForm.qml:313-318).
Not verified: the HTML renderer (morph::render) — this issue is about the
Qt/QML one only. I did not check whether any shipped rung currently renders an
enum member through DynamicForm (bookmarks' CreateBookmark::visibility is a
plain reflected enum and would hit this, but I did not run its GUI).
Where it bites today
examples/kanban (the ladder's designated showcase). Converting its remaining
two screens to schema-driven forms is blocked on exactly this:
SetMemberRole::role (gui/qml/MembersView.qml) and
CreateRule::mutationType (gui/qml/RulesView.qml) are each a hand-built
three- or two-item ComboBox today, and rendering them would replace a picker
with a typo-accepting text field. They are therefore left hand-built under
examples/IMPLEMENTATION.md rule 2's justification (a), with this issue as the
gap that justification requires — see that rung's README and morph#344.
examples/IMPLEMENTATION.md rule 3 makes this general rather than incidental:
"Closed sets of states/options → enum class (never a bare integer, never
bool)". Every rung is instructed to spell closed sets exactly the way the
shipped renderer cannot draw.
A shape for the fix
resolveProp should recognise "every branch of this oneOf/anyOf (bar
null) carries a const" as a distinct case from "these branches differ only
in nullability", and fields should carry the resulting value/label pairs.
Rendering can then reuse the combo box isChoice already draws — the only
difference is that the options are in the schema instead of behind
x-optionsAction, so no fetchOptions round trip is needed. The bare JSON
Schema enum keyword deserves the same treatment for a hand-written schema.
fieldJsonLiteral should reject a value outside the set, so ready means what
it says.
What would change the verdict
Close this when a schema whose property is an oneOf of consts renders as a
selection control and a value outside the set leaves the form !ready. Re-open
if a later resolveProp change silently re-collapses the branches. The scratch
probe above is not committed; the assertion belongs in
src/qt/forms/tests/tst_dynamicform.qml, whose harness is where I ran it.
morph::forms::schemaJson<A>()fully describes a C++enum classmember: glazeemits it as a closed set of
constalternatives, each with its owntitle.The shipped renderer throws all of that away.
DynamicForm.qml'sresolveProp(
src/qt/forms/qml/DynamicForm.qml:175-203) collapses anoneOfto its firstnon-null branch — a path added for the nullable-
$refshape morph#189 fixed,where the branches differ only in nullability — and then
fields(
:248-380) never looks atconstorenumat all.isChoiceis set solelyby the presence of
x-optionsAction. So a closed enum renders as a plainTextFieldand any string the user types is submitted verbatim.Verification status: reproduced
Measured at
1f7a4619(branchscenario-corpus-both-axes) on a-DMORPH_BUILD_FORMS_QML=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=kanbanconfigure, Qt 6.11.2,
QT_QPA_PLATFORM=offscreen.A scratch
tst_*.qmlrun through the shipped suite's own runner(
morph_forms_qml_tests -input <dir>), with two schemas pasted verbatimfrom
schemaJson<kanban::SetMemberRole>()/schemaJson<kanban::CreateRule>()and bound declaratively (
schema: JSON.parse(...), i.e. the same shape everyapp uses):
Two separate consequences, both in that output:
fully enumerates, with the alternatives'
titles (already the human labels)discarded.
ready == trueand a body is assembledfor
role = "Emperor"/mutationType = "Explode". The server rejects it(glaze's enum reader refuses an unknown name), so this is a UX defect and not
a correctness hole — but the whole point of the client-side gate is to say
"this cannot be submitted" before the round trip, and here it says the
opposite. Compare
isBoolean, which exists precisely so a two-valued fieldis not a text box that accepts
"banana"(DynamicForm.qml:313-318).Not verified: the HTML renderer (
morph::render) — this issue is about theQt/QML one only. I did not check whether any shipped rung currently renders an
enum member through
DynamicForm(bookmarks'CreateBookmark::visibilityis aplain reflected enum and would hit this, but I did not run its GUI).
Where it bites today
examples/kanban(the ladder's designated showcase). Converting its remainingtwo screens to schema-driven forms is blocked on exactly this:
SetMemberRole::role(gui/qml/MembersView.qml) andCreateRule::mutationType(gui/qml/RulesView.qml) are each a hand-builtthree- or two-item
ComboBoxtoday, and rendering them would replace a pickerwith a typo-accepting text field. They are therefore left hand-built under
examples/IMPLEMENTATION.mdrule 2's justification (a), with this issue as thegap that justification requires — see that rung's README and morph#344.
examples/IMPLEMENTATION.mdrule 3 makes this general rather than incidental:"Closed sets of states/options →
enum class(never a bare integer, neverbool)". Every rung is instructed to spell closed sets exactly the way theshipped renderer cannot draw.
A shape for the fix
resolvePropshould recognise "every branch of thisoneOf/anyOf(barnull) carries aconst" as a distinct case from "these branches differ onlyin nullability", and
fieldsshould carry the resulting value/label pairs.Rendering can then reuse the combo box
isChoicealready draws — the onlydifference is that the options are in the schema instead of behind
x-optionsAction, so nofetchOptionsround trip is needed. The bare JSONSchema
enumkeyword deserves the same treatment for a hand-written schema.fieldJsonLiteralshould reject a value outside the set, soreadymeans whatit says.
What would change the verdict
Close this when a schema whose property is an
oneOfofconsts renders as aselection control and a value outside the set leaves the form
!ready. Re-openif a later
resolvePropchange silently re-collapses the branches. The scratchprobe above is not committed; the assertion belongs in
src/qt/forms/tests/tst_dynamicform.qml, whose harness is where I ran it.