Skip to content

Commit 57f24ce

Browse files
Yaraslautclaude
andauthored
kanban: render the remaining forms, and file the gap that stops the last two (#389)
#357 converted the login form and left the rest of the showcase hand-built. This finishes it: `CreateProject`, `CreateColumn`, `CreateSwimlane`, `CreateTask` and `AddComment` now render from `morph::forms::schemaJson<A>()` through the shipped `DynamicForm`, each with the renderer's own explicit Submit button. Six forms on five screens, none with a hand-written field or submit button. `BoardBridge` grows the controller contract the renderer calls -- `schemasJson`, `submitIfValid`, `replyReceived` -- forwarding to a `BoardPresenter::submitForm` that dispatches through `executeJson` and then decodes the reply. Decoding is the load-bearing part: every board action returns the full rebuilt state, so `submitForm` re-emits `boardOpened` (and `commentAdded` for `AddComment`), which is what keeps `BoardBridge::board` and every binding over it live in the same turn as the submit rather than at the next poll tick. `ProjectAdminPresenter::submitForm` becomes the two-model routing table its own comment predicted, and re-emits `projectCreated` with the name recovered from the submitted body so the project pane keeps re-listing itself. Both bridges serve one schema document and route only their own actions; an action a controller does not serve is reported on `replyReceived`, never dropped -- checked on `MoveTaskPosition`, a registered action deliberately outside the form set, so the gate is proven rather than the JSON decoder. `createProject`, `createColumn`, `createSwimlane`, `createTask` and `addComment` are demoted from `Q_INVOKABLE` to plain methods: nothing in `gui/qml/` calls them any more, and dead QML surface is what `test_kanban_qml_surface.cpp` exists to catch. `submitIfValid` needs the opposite exemption on both bridges, its caller being the shipped `DynamicForm.qml`. Two DTO declarations carry the interaction the hand-built controls had. `CreateTask`'s column/swimlane ids and `AddComment`'s task id are context, not input -- the task goes in the column you are typing in, the comment on the task whose popup is open -- so they are `hidden` in `fieldMetadata` and seeded by the view that owns the form; no row id reaches the screen. `CreateColumn::wipLimit` joins `optionalFields`, because 0 already means unlimited and the `SpinBox` it replaced defaulted to it rather than demanding a number. Rendering these for real found what generating them could not, which is the point triage made when it said schema generation is necessary and not sufficient. `MembersView`'s role pickers and `RulesView`'s mutation-type picker are *not* converted, and now carry rule 2(a)'s written justification plus the finding rule 2 demands: `schemaJson` describes a `enum class` completely, and `DynamicForm` draws it as a free-text field that reports `ready` for `role = "Emperor"` (morph#386). Converting them would have replaced two working combo boxes with two typo-accepting text boxes. Two smaller renderer findings are filed rather than folded in: morph#387 (every form warns about `onOptionsReceived` on a controller with no `Choice` field -- invisible until a real controller is attached) and morph#388 (array-valued schema keys mistyped when `schema` is assigned rather than bound). The drag-and-drop board keeps its own rule 2(a) justification and files nothing: its schema renders fine, and a form is simply the wrong shape for a gesture whose inputs the gesture itself produces. `tests/test_gui_forms_render.cpp` is what makes "rendered" more than a claim: it loads the shipped `.qml` into a real engine with real bridges over a real backend and, per form, asserts the controls drawn, the body assembled and the row written. Mutation-checked twice -- dropping the hidden-field seeding leaves `CreateTask` permanently `!ready`, and dropping `hidden` from `CreateTask::columnId` makes the raw id column visible on screen. 151 kanban cases pass (was 140), 1672 in the whole configure; both scenario scripts still exit 0. Built with `MORPH_ENABLE_STRICT_COMPILATION=OFF` because morph#372 makes any AUTOMOC target unbuildable from a nested worktree, so the test binary was not compiled under the strict warning set; the two changed DTO headers were compiled separately under it and are clean. Closes #344. Claude-Session: https://claude.ai/code/session_01GnSnsebJ92Kti1D7m3RwYJ Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 001704e commit 57f24ce

22 files changed

Lines changed: 1466 additions & 157 deletions

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@ API surface).
1111

1212
### Added
1313

14+
- **Every form in the ladder's showcase GUI now renders through
15+
`morph::forms`.** `examples/kanban` gains schema-driven `CreateProject`,
16+
`CreateColumn`, `CreateSwimlane`, `CreateTask` and `AddComment` forms
17+
alongside the `Login` form it already had — six in total, none with a
18+
hand-written field or submit button. `BoardBridge` grows the same
19+
`schemasJson`/`submitIfValid`/`replyReceived` controller contract
20+
`ProjectAdminBridge` has, both serving one schema document
21+
(`gui_lib/kanban_schemas.hpp`); the five demoted `Q_INVOKABLE`s stay as plain
22+
C++ methods. `CreateTask`'s column/swimlane ids and `AddComment`'s task id are
23+
declared `hidden` in their DTOs' `fieldMetadata` and supplied by the view that
24+
owns the form, so "type a title in the column you want" survives the
25+
conversion with no row id on screen, and `CreateColumn::wipLimit` joins
26+
`optionalFields` so a blank WIP limit still means unlimited.
27+
`tests/test_gui_forms_render.cpp` drives each form in a real QML engine over a
28+
real backend and asserts the controls drawn, the body assembled and the row
29+
written.
30+
1431
- **An out-of-process scenario corpus covering every ladder rung.**
1532
`scripts/scenario/scenarios/` grows from 4 files to 72, and
1633
`scripts/scenario/scenario_coverage.py` now exits `0` on both of its axes:

examples/kanban/README.md

Lines changed: 92 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -124,45 +124,91 @@ Strand ordering under real contention (2), typed server-side validation (3),
124124
authorization at Kanboard's granularity (4), journal-derived activity + undo
125125
(5, 6), the full offline stack (7), shared board instances throughout.
126126

127-
**`morph::forms`: the login form only.** `gui/qml/LoginView.qml` renders
128-
`Login` through `morph::forms::schemaJson<Login>()` and the shipped
129-
`MorphForms` `DynamicForm`, submitting via the renderer's own explicit Submit
130-
button — the same shape `bookmarks`, `polls`, `lims` and `pastebin` ship. It
131-
has no hand-written field and no hand-written submit button; if `Login` grows
132-
a second member, that file does not change.
127+
**`morph::forms`: every form on every screen.** Six of this rung's inputs are
128+
rendered from `morph::forms::schemaJson<A>()` through the shipped `MorphForms`
129+
`DynamicForm`, each submitting through the renderer's own explicit Submit
130+
button — the same shape `bookmarks`, `polls`, `lims` and `pastebin` ship:
133131

134-
Every *other* input in this rung is still hand-built Qt Quick, which
135-
[`IMPLEMENTATION.md`](../IMPLEMENTATION.md)'s rule 2 forbids by default and
136-
which this README owes a per-element justification for. It does not have one,
137-
and the honest position is that most of these have no justification available:
132+
| Form | Action | Screen |
133+
|---|---|---|
134+
| Sign in | `Login` | `gui/qml/LoginView.qml` |
135+
| Create project | `CreateProject` | `gui/qml/ProjectListView.qml` |
136+
| Add column | `CreateColumn` | `gui/qml/BoardView.qml` |
137+
| Add swimlane | `CreateSwimlane` | `gui/qml/BoardView.qml` |
138+
| New task | `CreateTask` | `gui/qml/BoardView.qml`, one instance per column |
139+
| Add comment | `AddComment` | `gui/qml/TaskDetailPopup.qml` |
140+
141+
Not one of them has a hand-written field or a hand-written submit button; if any
142+
of those six actions grows a member, no `.qml` file changes. Both QML bridges
143+
expose the one schema document (`gui_lib/kanban_schemas.hpp`) and the
144+
`submitIfValid` controller contract the renderer calls, and each routes only the
145+
actions its own models serve — an action a controller does not serve is reported
146+
back on `replyReceived`, never dropped.
147+
148+
Two of those actions carry ids that are *context rather than input* — which
149+
column a task is created in, which task a comment lands on. They are declared
150+
`hidden` in the DTO's own `fieldMetadata` (rule 3's "the DTO *is* the form
151+
definition"), so the renderer draws no control for them and the view that owns
152+
the form supplies them with `setFieldValue`. That keeps "type a title in the
153+
column you want it in" working without a raw row id ever appearing on screen.
154+
155+
**This is rendered, not merely generated.** `tests/test_gui_forms_render.cpp`
156+
loads the shipped `.qml` into a real QML engine with real bridges over a real
157+
backend, and for each of the five non-login forms asserts the controls the
158+
renderer drew, the body it assembled, and the row the model ended up with. It
159+
was mutation-checked twice: dropping the hidden-field seeding leaves `CreateTask`
160+
permanently `!ready`, and dropping `hidden` from `CreateTask::columnId` makes the
161+
raw id column visible.
138162

139-
| Hand-built input | Action behind it | Rule 2 status |
163+
### What is still hand-built, and why
164+
165+
[`IMPLEMENTATION.md`](../IMPLEMENTATION.md)'s rule 2 forbids hand-built input
166+
widgets by default and allows exactly two justifications, each of which has to
167+
be written here. Three elements remain, and all three are rule 2(a) — "the
168+
generated UI *cannot* express the interaction" — which rule 2 also makes a
169+
forms-subsystem finding, so each is filed:
170+
171+
| Hand-built input | Action | Rule 2 justification |
140172
|---|---|---|
141-
| `gui/qml/RulesView.qml:89`, `:97`, `:103` | rule definition | **Unjustified.** Not yet attempted through the renderer. |
142-
| `gui/qml/MembersView.qml:56`, `:81`, `:87` | `SetMemberRole` / `RemoveMember` | **Unjustified.** A principal string plus a role `enum class` is squarely in the forms palette. |
143-
| `gui/qml/ProjectListView.qml:125` | `CreateProject` | **Unjustified.** One `std::string name`. |
144-
| `gui/qml/BoardView.qml` column/task/comment entry | `CreateColumn`, `CreateSwimlane`, `CreateTask`, `AddComment` | **Unjustified.** `schemaJson<A>()` renders all four today — verified, see below. |
145-
| `gui/qml/BoardView.qml` drag-and-drop board | `MoveTaskPosition` | **Rule 2(a), plausibly.** A drag gesture is not a form somebody fills in; a schema-rendered version of it would be beside the point. Still owes a written finding if kept. |
173+
| `gui/qml/BoardView.qml`'s drag-and-drop board | `MoveTaskPosition` | **(a).** A drag is a gesture, not a form. See below. |
174+
| `gui/qml/MembersView.qml`'s role pickers (`:70`, `:101`) | `SetMemberRole` | **(a).** The renderer cannot draw an `enum class`. [#386](https://github.com/LASTRADA-Software/morph/issues/386) |
175+
| `gui/qml/RulesView.qml`'s add-rule row (`:105`, `:113`) | `CreateRule` | **(a).** Same enum gap. [#386](https://github.com/LASTRADA-Software/morph/issues/386) |
176+
177+
**The two enum pickers are a genuine subsystem gap, and it is measured.**
178+
`morph::forms::schemaJson` describes a C++ `enum class` completely — `Role`
179+
emits as `{"type":"string","oneOf":[{"title":"Viewer","const":"Viewer"},…]}`
180+
and `DynamicForm` renders that as a plain `TextField`: with those exact schemas
181+
it draws `TextField(objectName "field_role")` / `TextField(objectName
182+
"field_mutationType")`, reports `ready == true` for `role = "Emperor"` and
183+
`mutationType = "Explode"`, and assembles `{"role":"Emperor"}` for submission.
184+
Converting either screen would replace a working three- or two-item `ComboBox`
185+
with a text box that accepts any string at all, so they stay until
186+
[#386](https://github.com/LASTRADA-Software/morph/issues/386) lands. Everything
187+
else on both screens is already schema-shaped; `RulesView`'s trigger-column
188+
picker additionally wants `CreateRule::triggerColumnId` to become a
189+
`morph::forms::Choice<…, "GetBoardState">`, which is what rule 3 already
190+
prescribes for a user-chosen foreign key.
146191

147-
The claim in that last column is measured, not assumed:
148-
`morph::forms::schemaJson<A>()` was compiled against this rung's real,
149-
unmodified DTOs and produces a usable schema for `Login`, `CreateColumn`,
150-
`CreateSwimlane`, `CreateTask`, `AddComment` and `MoveTaskPosition` alike.
151-
The rule-3 strong ids are not a blocker — `TaskId` emits as
152-
`{"type":["integer","null"],…}` under `$defs`, and `DynamicForm` resolves
153-
`$ref` into `$defs` — and `MoveTaskPosition::optionalFields` is honoured.
154-
So rule 2's justification (a), "the generated UI *cannot* express the
155-
interaction", is **not available** for any of the form-shaped rows above.
192+
**The drag-and-drop board is not a subsystem gap, and this README should not
193+
pretend otherwise.** `schemaJson<MoveTaskPosition>()` produces a perfectly good
194+
schema — `taskId`/`columnId`/`swimlaneId`/`position` required, `opId` honoured
195+
as optional — and `DynamicForm` would render it without complaint. What is
196+
missing is not an expressive capacity; it is that nobody moves a card by typing
197+
four row ids into number fields and pressing Submit. The destination column,
198+
swimlane and position are *produced by the gesture itself* (`DropArea.onDropped`
199+
computes them from the drop's own geometry), so the form has no input left to
200+
collect. A form renderer is the wrong shape for a direct-manipulation gesture,
201+
and asking `morph::forms` to grow one would be asking it to become a different
202+
kind of library. Rule 2(a) applies on those grounds — the interaction, not the
203+
schema — which is why nothing is filed against `morph::forms` for it.
156204

157-
That leaves converting them, which is tracked as
158-
[morph#344](https://github.com/LASTRADA-Software/morph/issues/344). Login went
159-
first because it is the smallest complete instance of the whole path — schema
160-
document, controller contract, renderer, typed reply — and it establishes the
161-
pattern the rest follow. The rung's GUI design spec
162-
(`docs/superpowers/specs/2026-08-17-kanban-gui-design.md` §4) settles the
163-
two-bridge/property-bag architecture and never mentions forms, schemas or
164-
`DynamicForm`, so it was never the justification the QML comments treated it
165-
as.
205+
Two smaller findings fell out of rendering these forms for real, both filed
206+
rather than folded in: every `DynamicForm` warns about `onOptionsReceived`
207+
against any controller with no `Choice` field
208+
([#387](https://github.com/LASTRADA-Software/morph/issues/387)), and the
209+
renderer mistypes array-valued schema keys when `schema` is assigned as a
210+
`QVariant` instead of bound
211+
([#388](https://github.com/LASTRADA-Software/morph/issues/388)).
166212

167213
## Expected strain points
168214

@@ -266,8 +312,19 @@ findings were originally `r4-001`/`r4-002` under the retired
266312
— the replay-attempt budget cannot tell an undelivered replay from a
267313
rejected one, so reconnect flaps dead-letter work the server never saw.
268314
- [#344](https://github.com/LASTRADA-Software/morph/issues/344)
269-
the flagship GUI is hand-built with no `morph::forms` usage and no rule-2
315+
the flagship GUI was hand-built with no `morph::forms` usage and no rule-2
316+
justification. Six forms now render through the shipped renderer and the
317+
three remaining hand-built elements each carry their written rule-2(a)
270318
justification (see "morph subsystems exercised").
319+
- [#386](https://github.com/LASTRADA-Software/morph/issues/386)
320+
`DynamicForm` renders a C++ `enum class` as a free-text field, which is what
321+
keeps `MembersView`/`RulesView` hand-built.
322+
- [#387](https://github.com/LASTRADA-Software/morph/issues/387)
323+
every `DynamicForm` warns about `onOptionsReceived` on a controller with no
324+
`Choice` field.
325+
- [#388](https://github.com/LASTRADA-Software/morph/issues/388)
326+
`DynamicForm` mistypes array-valued schema keys when `schema` is assigned as
327+
a `QVariant` rather than bound.
271328

272329
Not filed here, deliberately: the applied-ops ledger that "Exactly-once has
273330
no owner in the stack" (below) forces every rung to rebuild is already

0 commit comments

Comments
 (0)