Skip to content

forms: DynamicForm renders a C++ enum class as a free-text field, and its gate accepts values outside the set #386

Description

@Yaraslaut

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: formsSubsystem: formsbugSomething isn't workingtriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions