Skip to content

Commit cae329e

Browse files
authored
kanban: render the login form through morph::forms (#357)
* kanban: render the login form through morph::forms `examples/LADDER.md` designates kanban the ladder's single polished showcase, and its GUI used none of `morph::forms`: seven QML files, 26 hand-written input controls, and zero schema-driven forms. That is what `examples/IMPLEMENTATION.md` rule 2 forbids by default, and the rung README recorded the gap without supplying either justification rule 2 allows. Triage settled which of the two applies by running the experiment rather than arguing it: `morph::forms::schemaJson<A>()` compiled against this rung's real, unmodified DTOs produces a usable schema for `Login`, `CreateColumn`, `CreateSwimlane`, `CreateTask`, `AddComment` and `MoveTaskPosition` alike. The rule-3 strong ids are not a blocker -- `TaskId` emits as `{"type":["integer","null"],...}` under `$defs`, and `DynamicForm` resolves `$ref` into `$defs` -- and `MoveTaskPosition::optionalFields` is honoured. So justification (a), "the generated UI cannot express the interaction", is not available for any of the form-shaped inputs here. `LoginView.qml` is converted first: it is the smallest complete instance of the whole path -- schema document, controller contract, shipped renderer, typed reply -- and it establishes the pattern the remaining screens follow. It now has no hand-written field and no hand-written submit button; if `Login` grows a second member, that file does not change. `Login` gains `explicitSubmit = true` so the renderer draws its own Submit button. Without it `DynamicForm` submits as soon as every required field is `ready`, which for a one-field login means firing mid-typing on the first username prefix that happens to be a valid principal. `ProjectAdminBridge` grows the controller contract the renderer calls: `schemasJson` and `submitIfValid`, forwarding to a presenter `submitForm` that dispatches through the type-erased `executeJson` path. `login` is demoted from `Q_INVOKABLE` to a plain method -- nothing in `gui/qml/` calls it any more, and dead QML surface is exactly what `test_kanban_qml_surface.cpp` exists to catch. `submitIfValid` needs an audit exemption for the opposite reason: its caller is the shipped `DynamicForm.qml`, outside this rung's tree, which the audit does not scan. `replyReceived` introduces a seam this rung did not have -- it broadcasts a result JSON to every bound QML handler, and `Login`'s result carries the token. `submitForm` redacts the token from its copy, and a new test asserts that directly; the pre-existing signal-parameter sweep only sees `QString` and could not have caught a leak through it. That test's comment claimed this rung had no such seam at all, which is no longer true, so it is rewritten rather than left to mislead. The README's "not exercised" paragraph becomes a per-element rule-2 ledger naming what is still hand-built, which action is behind it, and why each one is unjustified -- with the drag-and-drop board the single plausible (a). Verified on a `MORPH_BUILD_LADDER=ON -DMORPH_BUILD_FORMS_QML=ON` configure: 140 kanban tests pass, including the offscreen QML smoke test that loads Main.qml and every file reachable from it. That test was mutation-checked by pointing the import at a non-existent module, which fails it -- so its pass is real evidence the renderer resolves, not a vacuous one. Part of #344; the remaining screens are still to convert. Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com> * kanban: clang-format the forms-conversion changes The whole-tree clang-format gate flagged the presenter, the QML bridge and their test. No behaviour change; all 140 kanban cases still pass. Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com> --------- Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
1 parent 998df86 commit cae329e

10 files changed

Lines changed: 345 additions & 61 deletions

examples/kanban/README.md

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -124,25 +124,45 @@ 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-
**Not exercised: `morph::forms`.** Every screen in `gui/qml/` is hand-built
128-
Qt Quick. The rung's source trees hold three comment lines mentioning
129-
`MorphForms`/`FormsController` and no code that uses either, and every input
130-
is a hand-written `TextField`/`ComboBox`/`SpinBox`
131-
(`gui/qml/LoginView.qml:63`, `gui/qml/RulesView.qml:89`, `:97`, `:103`,
132-
`gui/qml/MembersView.qml:56`, `:81`, `:87`,
133-
`gui/qml/ProjectListView.qml:125`, plus `gui/qml/BoardView.qml`'s
134-
column/task/comment entry fields and `gui/qml/TaskDetailPopup.qml`'s comment
135-
field).
136-
[`IMPLEMENTATION.md`](../IMPLEMENTATION.md)'s rule 2 forbids hand-built input
137-
widgets by default and requires a written justification here for each one; no
138-
such justification has been written, and the GUI design spec the QML comments
139-
point at (`docs/superpowers/specs/2026-08-17-kanban-gui-design.md` §4)
140-
settles the bridge/property-bag architecture without addressing forms at all.
141-
Recorded as
142-
[#344](https://github.com/LASTRADA-Software/morph/issues/344) (the
143-
flagship-GUI forms-bypass finding) rather than justified after the fact here:
144-
deciding *which* of rule 2's two justifications applies is a design call this
145-
rung has not made.
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.
133+
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:
138+
139+
| Hand-built input | Action behind it | Rule 2 status |
140+
|---|---|---|
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. |
146+
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.
156+
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.
146166

147167
## Expected strain points
148168

examples/kanban/gui/qml/LoginView.qml

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,61 @@
11
// SPDX-License-Identifier: Apache-2.0
22
//
3-
// kanban's first screen. Dev-mode login: a username field, no password, no
4-
// schema-driven form -- unlike bookmarks/polls/pastebin, this rung's GUI
5-
// design spec (docs/superpowers/specs/2026-08-17-kanban-gui-design.md §4)
6-
// binds plain QVariantMap/QVariantList property bags rather than MorphForms'
7-
// DynamicForm, so Login's one field is a hand-built TextField here.
3+
// kanban's first screen. One schema-driven form and nothing else — no
4+
// hand-built username field and no hand-built submit button: the generated
5+
// form renders `Login`'s single `std::string username` member with its
6+
// required-gate, and the renderer's own Submit button submits it
7+
// (examples/IMPLEMENTATION.md rule 2, "schema-driven forms only"). If `Login`
8+
// ever grows a second field, this file does not change.
9+
//
10+
// This screen used to be a hand-built `TextField` + `Button`, on the strength
11+
// of this rung's GUI design spec
12+
// (docs/superpowers/specs/2026-08-17-kanban-gui-design.md §4) — which settles
13+
// the two-bridge/property-bag architecture and says nothing about forms at
14+
// all, so it never justified the exception rule 2 requires. See morph#344, and
15+
// the rung README's "morph subsystems exercised" section for what is still
16+
// hand-built here and what that costs.
817
//
918
// `projectAdminBridge` defaults to null so this same file also loads with
1019
// nothing wired up, which is exactly what the offscreen engine-load smoke
11-
// test (tests/test_gui_qml_smoke.cpp) does.
20+
// test (tests/test_gui_qml_smoke.cpp) does — and why `loginSchema` is read
21+
// defensively below rather than assuming a live bridge.
1222

1323
pragma ComponentBehavior: Bound
1424

1525
import QtQuick
1626
import QtQuick.Controls
1727
import QtQuick.Layouts
28+
import MorphForms
1829

1930
Item {
2031
id: page
2132

2233
/// The ProjectAdminBridge gui/main.cpp builds, or null when unwired.
2334
property var projectAdminBridge: null
2435

36+
/// schemaJson<Login>(), parsed out of the bridge's schema document.
37+
/// `({})` while unwired, which is what the smoke test loads.
38+
readonly property var loginSchema: page.projectAdminBridge === null
39+
? ({})
40+
: (JSON.parse(page.projectAdminBridge.schemasJson)["Login"] || ({}))
41+
2542
/// Whatever the last login attempt reported, shown verbatim.
2643
property string status: ""
2744
property bool statusIsError: false
2845

2946
Connections {
3047
target: page.projectAdminBridge
3148

32-
// Main.qml navigates on the successful loggedIn(principal) signal --
33-
// this only has to show a failure ("invalid principal", "handler not
34-
// bound", ...) rather than leave the user staring at a button that
35-
// seemed to do nothing.
36-
function onFailed(message) {
37-
page.status = message
38-
page.statusIsError = true
49+
// Login's outcome arrives here like every other form's. Main.qml
50+
// navigates on the successful loggedIn(principal) signal -- this only
51+
// has to show a failure ("invalid principal", "handler not bound",
52+
// ...) rather than leave the user staring at a button that seemed to
53+
// do nothing.
54+
function onReplyReceived(actionType, ok, payload) {
55+
if (actionType !== "Login")
56+
return
57+
page.status = ok ? "" : payload
58+
page.statusIsError = !ok
3959
}
4060
}
4161

@@ -60,23 +80,17 @@ Item {
6080
+ "kanban/dto/auth_dto.hpp for exactly what that does and does not mean."
6181
}
6282

63-
TextField {
64-
id: usernameField
65-
Layout.fillWidth: true
66-
placeholderText: "username"
67-
onAccepted: signInButton.clicked()
68-
}
69-
70-
Button {
71-
id: signInButton
83+
DynamicForm {
7284
Layout.fillWidth: true
73-
text: "Sign in"
74-
enabled: page.projectAdminBridge !== null && usernameField.text.length > 0
75-
onClicked: {
76-
page.status = ""
77-
page.statusIsError = false
78-
page.projectAdminBridge.login(usernameField.text)
79-
}
85+
actionType: "Login"
86+
schema: page.loginSchema
87+
// Bound, and safe to bind: `Login` declares `explicitSubmit = true`
88+
// (kanban/dto/auth_dto.hpp), so its schema carries
89+
// `"x-submitMode": "explicit"` and the renderer never auto-submits
90+
// -- it renders its own Submit button, gated on the same `ready`
91+
// state, and that click is the sole trigger
92+
// (docs/spec/forms/forms.md, "Explicit submit mode").
93+
controller: page.projectAdminBridge
8094
}
8195

8296
Label {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
#pragma once
3+
4+
#include <morph/forms/forms.hpp>
5+
#include <string>
6+
7+
#include "kanban/dto/auth_dto.hpp"
8+
9+
/// @file
10+
/// The `{actionType: schemaJson<A>()}` document kanban's schema-driven forms
11+
/// render from.
12+
///
13+
/// One place, so every shell that builds a `ProjectAdminBridge` — the desktop
14+
/// client and the tests alike — renders from the same document rather than
15+
/// each assembling its own. Mirrors `bookmarks/gui_lib/bookmark_schemas.hpp`,
16+
/// which established the shape.
17+
///
18+
/// **Only `Login` is here today.** This rung's other inputs are still
19+
/// hand-built Qt Quick, which `examples/IMPLEMENTATION.md` rule 2 forbids by
20+
/// default — see the rung README's "morph subsystems exercised" section for
21+
/// the per-element accounting and morph#344 for why they are not all converted
22+
/// at once.
23+
24+
namespace kanban::gui {
25+
26+
/// @brief Builds the schema document for every action this rung renders.
27+
///
28+
/// Not cached here: `morph::forms::schemaJson<A>()` already memoises one
29+
/// string per compiled action type, so the only cost repeated here is the
30+
/// concatenation, which happens once per shell at construction.
31+
/// @return `{actionType: schema}` JSON.
32+
[[nodiscard]] inline std::string kanbanSchemasJson() {
33+
return std::string{"{\"Login\":"} + ::morph::forms::schemaJson<kanban::Login>() + "}";
34+
}
35+
36+
} // namespace kanban::gui

examples/kanban/gui_lib/project_admin_presenter.cpp

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// SPDX-License-Identifier: Apache-2.0
22
#include "project_admin_presenter.hpp"
33

4+
#include <glaze/glaze.hpp>
45
#include <morph/session/session.hpp>
6+
#include <string>
57
#include <utility>
68

79
#include "gui/error_text.hpp"
@@ -33,6 +35,57 @@ void ProjectAdminPresenter::login(const QString& username) {
3335
[this](const std::exception_ptr& err) { reportError(err); });
3436
}
3537

38+
void ProjectAdminPresenter::submitForm(const QString& actionType, const QString& bodyJson) {
39+
// `executeJson` is the type-erased counterpart of the typed `execute`
40+
// calls below: the schema renderer only ever knows an action by the string
41+
// the schema names it with. Routing is a single handler today because
42+
// `Login` is the only schema-driven form in this rung — see
43+
// `kanban_schemas.hpp` and morph#344. When a second model's action joins
44+
// the document, this becomes the same actionType->handler table
45+
// `bookmarks::gui::BookmarkFormsController::dispatch` already has, and the
46+
// unroutable case must report rather than silently drop: QML names types
47+
// as strings, so a typo has to arrive somewhere a human reads it.
48+
const std::string type = actionType.toStdString();
49+
if (type != "Login") {
50+
emit formReplyReceived(actionType, false,
51+
QStringLiteral("no model in this client serves action '") + actionType + u'\'');
52+
return;
53+
}
54+
track<std::string>(
55+
_authHandler.executeJson(type, bodyJson.toStdString()),
56+
[this, actionType](std::string resultJson) {
57+
// A successful Login is the one reply this client reads rather
58+
// than merely displays: the token has to be installed before
59+
// anything else dispatches.
60+
// The same glaze reflection the wire used, so nothing here parses
61+
// JSON by hand; `read_json` returns a truthy error context on
62+
// failure. Named the same way, for the same reason, as
63+
// `bookmarks::gui::decodeLoginResult`.
64+
LoginResult result;
65+
if (glz::read_json(result, resultJson)) {
66+
emit formReplyReceived(actionType, false,
67+
QStringLiteral("login succeeded but its reply could not be decoded"));
68+
return;
69+
}
70+
onLoginSucceeded(result);
71+
// The token has already done its one job -- installed onto the
72+
// session above -- so it has no reason to leave this function.
73+
// `formReplyReceived` reaches every bound QML handler, and one
74+
// that rendered `payload` unconditionally would otherwise put a
75+
// live bearer credential on screen (and into any screenshot of
76+
// it). Re-encoding a redacted copy keeps the signal's shape
77+
// unchanged rather than making this a QML surface change. Same
78+
// reasoning, and the same redaction, as
79+
// `bookmarks::gui::FormsBridge::submitIfValid`.
80+
LoginResult redacted = result;
81+
redacted.token = AuthToken{};
82+
emit formReplyReceived(actionType, true, QString::fromStdString(glz::write_json(redacted).value_or("{}")));
83+
},
84+
[this, actionType](const std::exception_ptr& err) {
85+
emit formReplyReceived(actionType, false, ::morph::ladder::gui::errorText(err));
86+
});
87+
}
88+
3689
void ProjectAdminPresenter::refreshProjects() {
3790
track<GetMyProjectsResult>(
3891
_projectHandler.execute(GetMyProjects{}),

examples/kanban/gui_lib/project_admin_presenter.hpp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ class ProjectAdminPresenter : public ::morph::ladder::gui::Presenter {
5050
/// @param username The identity to log in as.
5151
void login(const QString& username);
5252

53+
/// @brief Dispatches @p bodyJson as @p actionType's body through the
54+
/// type-erased `executeJson` path, for the schema-driven forms
55+
/// `DynamicForm` renders.
56+
///
57+
/// The schema renderer names action types as strings and hands back an
58+
/// assembled JSON body, so this is the entry point it needs — the typed
59+
/// `login()` above stays for callers that already have a `QString`
60+
/// username. A successful `Login` still installs its token here rather
61+
/// than in the QML bridge, so the one place this rung learns an identity
62+
/// is unchanged by which of the two paths dispatched it.
63+
///
64+
/// @param actionType Registered action type id, as the schema names it.
65+
/// @param bodyJson Fully-assembled JSON body, as `DynamicForm` builds it.
66+
void submitForm(const QString& actionType, const QString& bodyJson);
67+
5368
/// @brief Lists every project the caller has any role on. Emits
5469
/// `projectsListed` on success, `failed` on error.
5570
void refreshProjects();
@@ -107,6 +122,18 @@ class ProjectAdminPresenter : public ::morph::ladder::gui::Presenter {
107122
/// `std::exception::what()`, ready for direct display.
108123
void failed(QString message);
109124

125+
/// @brief Emitted once per `submitForm`, carrying that form's outcome.
126+
///
127+
/// Separate from `failed` because a form's own view shows its own result
128+
/// inline, next to the fields that produced it, rather than through the
129+
/// shell's shared error surface.
130+
/// @param actionType The action the reply belongs to.
131+
/// @param ok Whether the dispatch succeeded.
132+
/// @param payload Result JSON on success, the error message otherwise.
133+
/// A successful `Login`'s token is redacted first — see
134+
/// `submitForm`'s definition.
135+
void formReplyReceived(QString actionType, bool ok, QString payload);
136+
110137
private:
111138
/// @brief Installs @p result's token as the shared `Bridge`'s default
112139
/// session, so every subsequent action from every presenter

examples/kanban/gui_lib/project_admin_qml_bridge.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <utility>
77

88
#include "gui/id_qml.hpp"
9+
#include "kanban_schemas.hpp"
910

1011
namespace kanban::gui {
1112

@@ -78,10 +79,17 @@ ProjectAdminBridge::ProjectAdminBridge(::morph::bridge::Bridge& bridge, ::morph:
7879
connect(&_presenter, &ProjectAdminPresenter::memberRoleSet, this, &ProjectAdminBridge::memberRoleSet);
7980
connect(&_presenter, &ProjectAdminPresenter::memberRemoved, this, &ProjectAdminBridge::memberRemoved);
8081
connect(&_presenter, &ProjectAdminPresenter::failed, this, &ProjectAdminBridge::failed);
82+
connect(&_presenter, &ProjectAdminPresenter::formReplyReceived, this, &ProjectAdminBridge::replyReceived);
8183
}
8284

85+
QString ProjectAdminBridge::schemasJson() const { return QString::fromStdString(kanbanSchemasJson()); }
86+
8387
void ProjectAdminBridge::login(const QString& username) { _presenter.login(username); }
8488

89+
void ProjectAdminBridge::submitIfValid(const QString& actionType, const QString& bodyJson) {
90+
_presenter.submitForm(actionType, bodyJson);
91+
}
92+
8593
void ProjectAdminBridge::refreshProjects() { _presenter.refreshProjects(); }
8694

8795
void ProjectAdminBridge::createProject(const QString& name) { _presenter.createProject(name); }

0 commit comments

Comments
 (0)