From 34ca9c73e77f43db548f4d115a11a54fb62179a0 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 6 Jul 2026 13:17:27 +0200 Subject: [PATCH 01/13] docs: add design spec for reactive forms over BridgeHandler Plans replacing the forms demo's direct RemoteServer/wire::Envelope usage with Bridge/BridgeHandler, and making forms fire live via set<>/subscribe<> instead of an explicit submit button, without new per-action boilerplate. Co-Authored-By: Claude Sonnet 5 --- ...2026-07-06-reactive-forms-bridge-design.md | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-06-reactive-forms-bridge-design.md diff --git a/docs/superpowers/specs/2026-07-06-reactive-forms-bridge-design.md b/docs/superpowers/specs/2026-07-06-reactive-forms-bridge-design.md new file mode 100644 index 0000000..3cc5c9f --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-reactive-forms-bridge-design.md @@ -0,0 +1,203 @@ +# Reactive forms demo over BridgeHandler + +Date: 2026-07-06 + +## Problem + +`examples/forms/gui_qml/FormsController` does not use the framework's real +client API. It builds `morph::wire::Envelope`s by hand and feeds them +directly to an in-process `morph::backend::RemoteServer`, bypassing +`Bridge`/`BridgeHandler` entirely. That is internal server-side wire +protocol plumbing — no real client is expected to touch `wire::Envelope` or +`RemoteServer` directly. Every other GUI in this codebase (`examples/bank`) +talks to models exclusively through `BridgeHandler`. + +Separately, the forms demo is not reactive: each form has an explicit +"execute" button; nothing recomputes until the user clicks it. The +framework already has a live, field-by-field, auto-fire API for exactly +this shape of UI — `BridgeHandler::set<&Action::field>(value)` / +`subscribe(cb)` — used today only in tests +(`tests/test_subscription.cpp`) and not demonstrated in any GUI example. + +The obstacle to adopting `set<>` directly from QML: it is a compile-time +API (a non-type template parameter per field), while `DynamicForm.qml` +discovers field names generically at runtime from the action's JSON +schema. Bridging that gap must not require new C++ per action — adding an +action to `lab_model.hpp` today only requires the struct plus one +`BRIDGE_REGISTER_ACTION` line, and that must remain true. + +## Goals + +- `FormsController` talks to `LabModel` exclusively through + `Bridge`/`BridgeHandler` — no `wire::Envelope`, no + `RemoteServer`, no `ActionDispatcher`/`ModelRegistryFactory` singletons + touched from the GUI layer. +- Forms fire automatically as soon as their required fields are valid + (live recompute), using `BridgeHandler::set<>` / `subscribe<>` under the + hood. No submit button in the reactive path. +- Zero new boilerplate per action: `lab_model.hpp` (and any future action + file) needs no changes beyond what `BRIDGE_REGISTER_ACTION` already + requires today. +- `examples/forms/main.cpp` (schema dump / `--emit-html` / REPL) is + untouched — it has no GUI and does not use `FormsController`. + +## Non-goals + +- No change to the JSON Schema shape (`morph::forms::schemaJson`) or to + `x-order`/`x-decimalPlaces`/`x-optionsAction` metadata. +- No change to `BridgeHandler`'s existing `execute()`/one-shot API — it + stays as-is for `fetchOptions` (`ListSamples` is a plain query, not a + fielded draft). +- No attempt to make the *options* combo-box fetch reactive; it remains + fetched once per form (as today), triggered from `Component.onCompleted`. +- The static `--emit-html` page (pure JS, no C++ backend at all) is out of + scope; it already works standalone and has its own test + (`test_html_math.mjs`). + +## Design + +### 1. `FieldSetterRegistry` (new, in `include/morph/bridge.hpp`) + +A process-wide singleton with the same shape as the existing +`ActionDispatcher` (`registry.hpp`): a static-init-populated map, keyed by +`(modelId, actionId, fieldName)`, of type-erased closures. + +```cpp +class FieldSetterRegistry { +public: + // Reads `jsonValue` into the field's real type and calls the + // compile-time handler.set<&Action::field>(value) on it. + using Setter = std::function* */, std::string_view jsonValue)>; + + template + void registerAction(std::string_view modelId, std::string_view actionId); + + void set(std::string_view modelId, std::string_view actionId, std::string_view fieldName, + void* handler, std::string_view jsonValue) const; // throws if unknown + + static FieldSetterRegistry& instance(); +}; +``` + +`registerAction` iterates `Action`'s reflected members with +`morph::forms::detail::forEachNamedMember` (already exists, already used +by `schemaJson`/`allRequiredEngaged` — no new reflection mechanism). For +each member it captures a closure: + +```cpp +[](void* handlerVoid, std::string_view jsonValue) { + auto* handler = static_cast*>(handlerVoid); + using FieldType = /* member's real type */; + FieldType value{}; + if (glz::read_json(value, jsonValue)) { + throw std::runtime_error("invalid field value"); + } + handler->template set(std::move(value)); +} +``` + +`Quantity`, `Choice`, `morph::time::Timestamp`, `std::optional`, +and plain scalars all already round-trip through `glz::read_json` (`Choice` +and `Quantity` each have a `glz::meta` specialisation), so one generic +closure body covers every field kind with no per-type branching. + +**Registration point:** `BRIDGE_REGISTER_ACTION_4` (in `registry.hpp`) +gains one extra line calling +`morph::model::detail::registerFieldSettersOnce(...)`, mirroring the +existing `registerActionOnce` call right next to it. This is the only +change to the macro; nothing a user writes changes. Existing calls to +`BRIDGE_REGISTER_ACTION` in `lab_model.hpp`, the bank example, and every +test keep compiling unchanged and gain field-setter registration for free. + +### 2. `BridgeHandler::setFieldJson` (new method, `bridge.hpp`) + +```cpp +void setFieldJson(std::string_view actionType, std::string_view fieldName, std::string_view jsonValue) { + FieldSetterRegistry::instance().set( + std::string{ModelTraits::typeId()}, actionType, fieldName, this, jsonValue); +} +``` + +Thin: looks up the closure, invokes it with `this`. Errors (unknown +action/field, bad JSON) throw `std::runtime_error`; `FormsController` +catches and reports via the existing error-signal path rather than +crashing the GUI. + +### 3. `FormsController` rewrite (`examples/forms/gui_qml/FormsController.{hpp,cpp}`) + +Drops: `morph::backend::RemoteServer`, `morph::wire::*`, `_modelId`, +`_nextCallId`, `_pool` sized for a manual server. + +Adds: +- `morph::exec::ThreadPoolExecutor _pool` (worker executor, same role as + `bankgui::BankClient::_pool`) and `morph::qt::QtExecutor _gui`. +- `morph::bridge::Bridge _bridge{std::make_unique(_pool)}`. +- `morph::bridge::BridgeHandler _handler{_bridge, &_gui}`. +- One `subscribe(...)` call per known action type + (`ComputeDryDensity`, `RecordMeasurement`) registered in the + constructor — this is the one place per-action code still exists, + matching `AccountController`'s pattern of one `.then()`/`.onError()` + pair per action. It is unavoidable without type erasure on the *result* + side too, and the design deliberately does not add that: results are + few, fixed, and already need type-specific formatting + (`humanize`/`toMap`-style) on the QML side eventually. `ListSamples` + keeps using plain `_handler.execute()` (a one-shot query, not a draft). +- `Q_INVOKABLE void setField(QString actionType, QString fieldName, QString jsonValue)` + → `_handler.setFieldJson(...)`, catches, emits `fieldError` on failure. +- Results surface via the same `replyReceived(actionType, ok, payload)` / + `optionsReceived(...)` signals QML already listens for — the *signal + surface* to QML does not change shape, only what feeds it. + +`submit(actionType, bodyJson)` is removed. `fetchOptions` stays, backed by +`_handler.execute(ListSamples{})` instead of a hand-built envelope. + +### 4. `DynamicForm.qml` + +`revalidate()` already computes `ready` and assembles field values; add: +when a field changes and the *whole form* is `ready`, call +`controller.setField(actionType, name, jsonValue)` for the field that +changed (not the whole form — `setFieldJson` sets one field at a time, +matching `set<>`'s per-field contract). The "execute" `Button` is removed +from the reactive path; the built line (`previewLine`) stays visible as a +read-only preview of what was last sent, and `resultText` updates live as +replies stream in via the unchanged `onReplyReceived` handler. + +### Error handling + +- Unknown `(actionType, fieldName)` (typo, stale schema): `setFieldJson` + throws; `FormsController::setField` catches and emits the existing + `error`-style signal rather than propagating into Qt's slot-invocation + exception boundary (undefined behaviour today if left uncaught). +- Bad JSON for a field's real type (e.g. non-numeric text for a + `Quantity`): same path — `glz::read_json` failure is surfaced as a + catchable exception, not a crash. The QML side already gates on + `revalidate()`'s own regex checks before calling `setField`, so this is + a defence-in-depth path, not the primary validation gate. +- `ActionValidator::ready()` (unchanged, existing mechanism) still + decides whether a call actually fires after each `set<>` — untouched by + this design. + +### Testing + +- New unit test (`tests/test_bridge_field_setters.cpp` or added to + `tests/test_subscription.cpp`): registers a test action via + `BRIDGE_REGISTER_ACTION`, calls `BridgeHandler::setFieldJson` for each + field with JSON literals, asserts the draft fires and the subscribed + callback receives the right result — covering scalars, `Quantity`, and + `Choice` fields to prove the generic closure works across field kinds. +- `examples/forms/gui_qml/tests/tst_main.cpp` (Qt Quick Test) gains a case + driving `DynamicForm` through `setField`-style edits and asserting + `resultText` updates without any button click. +- Existing `forms_qml_logic` test target and CTest wiring are unchanged. + +## Open questions / risks + +- `void*` type erasure in `FieldSetterRegistry::Setter` mirrors the + existing untyped-`Runner` pattern in `ActionDispatcher` + (`std::function`) rather + than introducing `std::any`; this keeps the new code consistent with + the codebase's existing type-erasure idiom. +- `setFieldJson` calling `set<>` inherits `set<>`'s existing coalescing + behaviour (bursts of `set<>` calls while a call is in flight collapse to + the latest draft, then refire) — no new concurrency behaviour is + introduced. From a35e4e8e78a955ae210b1bb87a5ed894f67c6498 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 6 Jul 2026 13:24:34 +0200 Subject: [PATCH 02/13] docs: correct reactive-forms spec after verifying glaze reflection limits set<&Action::field> needs a compile-time member pointer per field, and glaze's plain-aggregate reflection (reflectable, no glz::meta) exposes only field names, not member pointers -- confirmed directly against glaze/core/reflect.hpp. Replaced the set<>/subscribe<>-based design with a generic executeJson(actionType, bodyJson) path backed by a registry populated automatically inside BRIDGE_REGISTER_ACTION, preserving the zero-new-boilerplate-per-action goal. Co-Authored-By: Claude Sonnet 5 --- ...2026-07-06-reactive-forms-bridge-design.md | 233 ++++++++++-------- 1 file changed, 135 insertions(+), 98 deletions(-) diff --git a/docs/superpowers/specs/2026-07-06-reactive-forms-bridge-design.md b/docs/superpowers/specs/2026-07-06-reactive-forms-bridge-design.md index 3cc5c9f..5524241 100644 --- a/docs/superpowers/specs/2026-07-06-reactive-forms-bridge-design.md +++ b/docs/superpowers/specs/2026-07-06-reactive-forms-bridge-design.md @@ -13,18 +13,41 @@ protocol plumbing — no real client is expected to touch `wire::Envelope` or talks to models exclusively through `BridgeHandler`. Separately, the forms demo is not reactive: each form has an explicit -"execute" button; nothing recomputes until the user clicks it. The -framework already has a live, field-by-field, auto-fire API for exactly -this shape of UI — `BridgeHandler::set<&Action::field>(value)` / -`subscribe(cb)` — used today only in tests -(`tests/test_subscription.cpp`) and not demonstrated in any GUI example. - -The obstacle to adopting `set<>` directly from QML: it is a compile-time -API (a non-type template parameter per field), while `DynamicForm.qml` -discovers field names generically at runtime from the action's JSON -schema. Bridging that gap must not require new C++ per action — adding an -action to `lab_model.hpp` today only requires the struct plus one -`BRIDGE_REGISTER_ACTION` line, and that must remain true. +"execute" button; nothing recomputes until the user clicks it. + +**Investigated and rejected:** the framework's field-by-field, auto-fire +API — `BridgeHandler::set<&Action::field>(value)` / `subscribe(cb)` +(used today only in `tests/test_subscription.cpp`) — looked like the +natural fit, since it fires automatically once a draft becomes valid. +It does not work here: `set` requires a real compile-time +pointer-to-member (`double Action::*`) as a template argument, but +`DynamicForm.qml` only knows field names as runtime strings from the JSON +schema. Generating that pointer generically from glaze reflection was +checked directly against glaze's `reflect` implementation +(`glaze/core/reflect.hpp`): for a plain aggregate with no `glz::meta` +specialisation (every action in this codebase, e.g. `RecordMeasurement`), +glaze's `reflectable` reflection path exposes only field **names** +(`keys`) and a `to_tie()`-based tuple of runtime references — no +compile-time member-pointer artifact exists to extract. Producing one +per field would need either C++26 static reflection (not in use here) or +a hand-written `glz::meta` per action declaring each member +pointer explicitly, which reintroduces exactly the per-action boilerplate +this design exists to avoid. `set<>`/`subscribe<>` is therefore left as +what it is today — a compile-time API for hand-written C++ call sites — +and out of scope for the schema-driven QML forms. + +The chosen alternative keeps the *outcome* (fires automatically as the +user types, no button) without needing a field-level compile-time hook: +the controller assembles the full action body as JSON (the QML side +already does this for the current submit button) and calls a new +generic, JSON-in/JSON-out execute path through `Bridge`/`BridgeHandler` +whenever the assembled body is schema-valid. This still requires +resolving a runtime action-type string to a concrete C++ `Action` type to +call `BridgeHandler::execute()` — the same shape of problem, one +level up. It is solved the same way `ActionDispatcher` already solves it +for `RemoteServer` (`registry.hpp`): a static-init-populated map from +action-type string to a type-erased closure, built automatically inside +`BRIDGE_REGISTER_ACTION` (no new macro, no new per-action declaration). ## Goals @@ -33,8 +56,7 @@ action to `lab_model.hpp` today only requires the struct plus one `RemoteServer`, no `ActionDispatcher`/`ModelRegistryFactory` singletons touched from the GUI layer. - Forms fire automatically as soon as their required fields are valid - (live recompute), using `BridgeHandler::set<>` / `subscribe<>` under the - hood. No submit button in the reactive path. + (live recompute) — no submit button in the reactive path. - Zero new boilerplate per action: `lab_model.hpp` (and any future action file) needs no changes beyond what `BRIDGE_REGISTER_ACTION` already requires today. @@ -56,72 +78,80 @@ action to `lab_model.hpp` today only requires the struct plus one ## Design -### 1. `FieldSetterRegistry` (new, in `include/morph/bridge.hpp`) +### 1. `ActionExecuteRegistry` (new, in `include/morph/bridge.hpp`) A process-wide singleton with the same shape as the existing `ActionDispatcher` (`registry.hpp`): a static-init-populated map, keyed by -`(modelId, actionId, fieldName)`, of type-erased closures. +`(modelId, actionId)`, of type-erased closures — except this closure calls +through `Bridge::executeVia` (routing, sessions, backend +switches, completions) instead of `ActionDispatcher::Runner`, which calls +`Model::execute` directly against an already-owned `IModelHolder` and is +only ever invoked server-side (inside `RemoteServer`/`ActionDispatcher` +dispatch, never from a client). ```cpp -class FieldSetterRegistry { +class ActionExecuteRegistry { public: - // Reads `jsonValue` into the field's real type and calls the - // compile-time handler.set<&Action::field>(value) on it. - using Setter = std::function* */, std::string_view jsonValue)>; + // Deserialises `bodyJson` into the concrete Action, dispatches it + // through the handler's Bridge, and resolves with the JSON-encoded + // result (or the exception, unchanged) on the handler's gui executor. + using Executor = std::function<::morph::async::Completion(void* /* BridgeHandler* */, + std::string_view bodyJson)>; template void registerAction(std::string_view modelId, std::string_view actionId); - void set(std::string_view modelId, std::string_view actionId, std::string_view fieldName, - void* handler, std::string_view jsonValue) const; // throws if unknown + ::morph::async::Completion execute(std::string_view modelId, std::string_view actionId, + void* handler, std::string_view bodyJson) const; // throws if unknown - static FieldSetterRegistry& instance(); + static ActionExecuteRegistry& instance(); }; ``` -`registerAction` iterates `Action`'s reflected members with -`morph::forms::detail::forEachNamedMember` (already exists, already used -by `schemaJson`/`allRequiredEngaged` — no new reflection mechanism). For -each member it captures a closure: +The registered closure: ```cpp -[](void* handlerVoid, std::string_view jsonValue) { +[](void* handlerVoid, std::string_view bodyJson) -> ::morph::async::Completion { auto* handler = static_cast*>(handlerVoid); - using FieldType = /* member's real type */; - FieldType value{}; - if (glz::read_json(value, jsonValue)) { - throw std::runtime_error("invalid field value"); - } - handler->template set(std::move(value)); + Action action = ActionTraits::fromJson(bodyJson); // already exists + auto resultState = std::make_shared<::morph::async::detail::CompletionState>(); + handler->template execute(std::move(action)) + .then([resultState](typename ActionTraits::Result result) { + resultState->setValue(ActionTraits::resultToJson(result)); // already exists + }) + .onError([resultState](std::exception_ptr err) { resultState->setException(err); }); + return {resultState, handler->guiExecutor()}; // BridgeHandler exposes its _guiExec } ``` -`Quantity`, `Choice`, `morph::time::Timestamp`, `std::optional`, -and plain scalars all already round-trip through `glz::read_json` (`Choice` -and `Quantity` each have a `glz::meta` specialisation), so one generic -closure body covers every field kind with no per-type branching. +`ActionTraits::fromJson`/`resultToJson` already exist (generated by +`BRIDGE_REGISTER_ACTION_4` for every action) — this closure only chains +them around the real `execute()` call. No new JSON codec, no new +reflection. **Registration point:** `BRIDGE_REGISTER_ACTION_4` (in `registry.hpp`) gains one extra line calling -`morph::model::detail::registerFieldSettersOnce(...)`, mirroring the -existing `registerActionOnce` call right next to it. This is the only +`morph::model::detail::registerActionExecutorOnce(...)`, mirroring +the existing `registerActionOnce` call right next to it. This is the only change to the macro; nothing a user writes changes. Existing calls to `BRIDGE_REGISTER_ACTION` in `lab_model.hpp`, the bank example, and every -test keep compiling unchanged and gain field-setter registration for free. +test keep compiling unchanged and gain generic-execute registration for +free — `lab_model.hpp` needs zero new lines. -### 2. `BridgeHandler::setFieldJson` (new method, `bridge.hpp`) +### 2. `BridgeHandler::executeJson` (new method, `bridge.hpp`) ```cpp -void setFieldJson(std::string_view actionType, std::string_view fieldName, std::string_view jsonValue) { - FieldSetterRegistry::instance().set( - std::string{ModelTraits::typeId()}, actionType, fieldName, this, jsonValue); +::morph::async::Completion executeJson(std::string_view actionType, std::string_view bodyJson) { + return ActionExecuteRegistry::instance().execute( + std::string{ModelTraits::typeId()}, actionType, this, bodyJson); } ``` -Thin: looks up the closure, invokes it with `this`. Errors (unknown -action/field, bad JSON) throw `std::runtime_error`; `FormsController` -catches and reports via the existing error-signal path rather than -crashing the GUI. +Thin: looks up the closure, invokes it with `this`. `ModelTraits` +is already required by every `BridgeHandler` instantiation, so no +new constraint is added. Needs a `guiExecutor()` accessor added to +`BridgeHandler` (it already stores `_guiExec`; today nothing outside the +class reads it back). ### 3. `FormsController` rewrite (`examples/forms/gui_qml/FormsController.{hpp,cpp}`) @@ -133,71 +163,78 @@ Adds: `bankgui::BankClient::_pool`) and `morph::qt::QtExecutor _gui`. - `morph::bridge::Bridge _bridge{std::make_unique(_pool)}`. - `morph::bridge::BridgeHandler _handler{_bridge, &_gui}`. -- One `subscribe(...)` call per known action type - (`ComputeDryDensity`, `RecordMeasurement`) registered in the - constructor — this is the one place per-action code still exists, - matching `AccountController`'s pattern of one `.then()`/`.onError()` - pair per action. It is unavoidable without type erasure on the *result* - side too, and the design deliberately does not add that: results are - few, fixed, and already need type-specific formatting - (`humanize`/`toMap`-style) on the QML side eventually. `ListSamples` - keeps using plain `_handler.execute()` (a one-shot query, not a draft). -- `Q_INVOKABLE void setField(QString actionType, QString fieldName, QString jsonValue)` - → `_handler.setFieldJson(...)`, catches, emits `fieldError` on failure. -- Results surface via the same `replyReceived(actionType, ok, payload)` / - `optionsReceived(...)` signals QML already listens for — the *signal - surface* to QML does not change shape, only what feeds it. - -`submit(actionType, bodyJson)` is removed. `fetchOptions` stays, backed by -`_handler.execute(ListSamples{})` instead of a hand-built envelope. +- `Q_INVOKABLE void submitIfValid(QString actionType, QString bodyJson)` + (called by QML whenever the assembled body is schema-valid, on every + edit) → `_handler.executeJson(actionType.toStdString(), bodyJson.toStdString())`, + `.then()`/`.onError()` emit the existing `replyReceived(actionType, ok, payload)` + signal. Fully generic: no per-action branch, no per-action subscription + to register in the constructor. +- `fetchOptions` stays, backed by `_handler.execute(ListSamples{})` + instead of a hand-built envelope (this one call site *is* per-action — + `ListSamples` is the one fixed, hand-known query the controller always + needs — but it already existed conceptually in the current code and + does not grow with the number of form actions). + +`submit(actionType, bodyJson)` is renamed `submitIfValid` and re-fires on +every call rather than only on a button click; the signal surface to QML +(`replyReceived`, `optionsReceived`) is unchanged. ### 4. `DynamicForm.qml` -`revalidate()` already computes `ready` and assembles field values; add: -when a field changes and the *whole form* is `ready`, call -`controller.setField(actionType, name, jsonValue)` for the field that -changed (not the whole form — `setFieldJson` sets one field at a time, -matching `set<>`'s per-field contract). The "execute" `Button` is removed -from the reactive path; the built line (`previewLine`) stays visible as a -read-only preview of what was last sent, and `resultText` updates live as -replies stream in via the unchanged `onReplyReceived` handler. +`revalidate()` already computes `ready` and assembles `previewLine` (the +full JSON body) on every edit. Add: when `ready` becomes (or stays) `true` +after an edit, call `controller.submitIfValid(actionType, previewLine)` +directly from `revalidate()` — no separate per-field call, since the +execute path takes the whole body already assembled exactly as it is +today. The "execute" `Button` is removed from the reactive path; the +built line (`previewLine`) stays visible as a preview of what was last +sent, and `resultText` updates live as replies stream in via the +unchanged `onReplyReceived` handler. ### Error handling -- Unknown `(actionType, fieldName)` (typo, stale schema): `setFieldJson` - throws; `FormsController::setField` catches and emits the existing - `error`-style signal rather than propagating into Qt's slot-invocation - exception boundary (undefined behaviour today if left uncaught). -- Bad JSON for a field's real type (e.g. non-numeric text for a - `Quantity`): same path — `glz::read_json` failure is surfaced as a - catchable exception, not a crash. The QML side already gates on - `revalidate()`'s own regex checks before calling `setField`, so this is - a defence-in-depth path, not the primary validation gate. -- `ActionValidator::ready()` (unchanged, existing mechanism) still - decides whether a call actually fires after each `set<>` — untouched by - this design. +- Unknown `actionType` (typo, stale schema): `executeJson` throws; + `FormsController::submitIfValid` catches and emits `replyReceived` with + `ok=false` rather than propagating into Qt's slot-invocation exception + boundary (undefined behaviour today if left uncaught). +- Bad/incomplete JSON body: `ActionTraits::fromJson` already + throws `ParseError` on malformed input (existing behaviour, exercised + today via `ActionDispatcher::dispatch`); same catch-and-report path. + The QML side already gates on `revalidate()`'s own regex/required-field + checks before calling `submitIfValid`, so this is a defence-in-depth + path, not the primary validation gate. +- `Model::execute` throwing (e.g. `RecordMeasurement`'s own + `validate()`-based guard): propagates through `Completion::onError` to + `replyReceived(actionType, false, message)` exactly as the current + `sendExecute` error path already does. ### Testing -- New unit test (`tests/test_bridge_field_setters.cpp` or added to - `tests/test_subscription.cpp`): registers a test action via - `BRIDGE_REGISTER_ACTION`, calls `BridgeHandler::setFieldJson` for each - field with JSON literals, asserts the draft fires and the subscribed - callback receives the right result — covering scalars, `Quantity`, and - `Choice` fields to prove the generic closure works across field kinds. +- New unit test (`tests/test_bridge_execute_json.cpp` or added to + `tests/test_bridge_local.cpp`): registers a test action via + `BRIDGE_REGISTER_ACTION`, calls `BridgeHandler::executeJson` with a JSON + body, asserts the `Completion` resolves with the correctly + JSON-encoded result — and a second case with a malformed body asserting + the completion's `onError` fires with a parse error, matching + `ActionTraits::fromJson`'s existing throw behaviour. - `examples/forms/gui_qml/tests/tst_main.cpp` (Qt Quick Test) gains a case - driving `DynamicForm` through `setField`-style edits and asserting + driving `DynamicForm` through simulated field edits and asserting `resultText` updates without any button click. - Existing `forms_qml_logic` test target and CTest wiring are unchanged. ## Open questions / risks -- `void*` type erasure in `FieldSetterRegistry::Setter` mirrors the +- `void*` type erasure in `ActionExecuteRegistry::Executor` mirrors the existing untyped-`Runner` pattern in `ActionDispatcher` (`std::function`) rather than introducing `std::any`; this keeps the new code consistent with the codebase's existing type-erasure idiom. -- `setFieldJson` calling `set<>` inherits `set<>`'s existing coalescing - behaviour (bursts of `set<>` calls while a call is in flight collapse to - the latest draft, then refire) — no new concurrency behaviour is - introduced. +- `executeJson` re-fires the whole action on every keystroke once the + form is valid, with no coalescing: a burst of edits while a call is + in flight will queue multiple concurrent `Bridge::executeVia` calls + (each independently resolving `replyReceived`, last-writer-wins on + `resultText`). This is a real behavioural difference from `set<>`'s + built-in coalescing (which this design deliberately does not use) and + should be noted in the demo's README; if it proves visibly janky in + practice, a debounce timer in `DynamicForm.qml` (fire ~150ms after the + last edit) is a self-contained follow-up, not a blocker for this plan. From 4e681c1c1d8cb4d6a89fa791db3804f3a2dcefbc Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 6 Jul 2026 13:28:44 +0200 Subject: [PATCH 03/13] docs: add implementation plan for reactive forms over BridgeHandler Six tasks: ActionExecuteRegistry + BridgeHandler::executeJson (with unit tests), FormsController rewrite onto Bridge/BridgeHandler, reactive DynamicForm.qml (auto-fire, no submit button), Qt Quick Test coverage, README update, and a full regression pass. Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-07-06-reactive-forms-bridge.md | 896 ++++++++++++++++++ 1 file changed, 896 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-reactive-forms-bridge.md diff --git a/docs/superpowers/plans/2026-07-06-reactive-forms-bridge.md b/docs/superpowers/plans/2026-07-06-reactive-forms-bridge.md new file mode 100644 index 0000000..e88aa1f --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-reactive-forms-bridge.md @@ -0,0 +1,896 @@ +# Reactive Forms Over BridgeHandler Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the QML forms demo (`examples/forms/gui_qml`) talk to `LabModel` through `morph::bridge::Bridge`/`BridgeHandler` instead of hand-built `wire::Envelope`s over `RemoteServer`, and make forms fire automatically as the user types (no submit button) — with zero new C++ declarations required per action. + +**Architecture:** A new `ActionExecuteRegistry` (in `include/morph/bridge.hpp`, alongside `Bridge`/`BridgeHandler`) maps `(modelId, actionId)` strings to type-erased closures that deserialize a JSON body, call the real compile-time `BridgeHandler::execute()`, and re-serialize the result. It is populated automatically inside the existing `BRIDGE_REGISTER_ACTION_4` macro (`include/morph/registry.hpp`) — every already-registered action gains a generic-execute entry for free. `BridgeHandler` gains one new method, `executeJson(actionType, bodyJson) -> Completion`, that looks up and invokes the registered closure. `FormsController` is rewritten to own a `Bridge` + `BridgeHandler` and exposes a single generic `Q_INVOKABLE submitIfValid(actionType, bodyJson)` entry point; `DynamicForm.qml` calls it from `revalidate()` on every edit once the assembled body is valid, replacing the explicit "execute" button. + +**Tech Stack:** C++23, glaze (JSON + reflection), Catch2, Qt 6 (Quick/Qml/QuickTest), CMake/Ninja, MSVC (primary) via vcpkg. + +## Global Constraints + +- `set<&Action::field>`/`subscribe` (BridgeHandler's compile-time fielded-draft API) is **out of scope** — confirmed unusable generically because glaze's plain-aggregate reflection (`reflectable`, no `glz::meta`) exposes only field names, not compile-time member pointers. Do not attempt to resurrect it inside this plan. +- Adding a new action to `lab_model.hpp` (or any `BRIDGE_REGISTER_ACTION` call site) must require **zero new lines** beyond what it needs today. Every task that touches `BRIDGE_REGISTER_ACTION` must preserve this. +- `examples/forms/main.cpp` (schema dump / `--emit-html` / REPL) is untouched — no GUI, does not use `FormsController`. +- No change to `morph::forms::schemaJson` output or its `x-*` extension keys. +- Type erasure in new registries follows the existing `ActionDispatcher::Runner` idiom (`std::function` over a `void*`/`IModelHolder&`), not `std::any` — match `include/morph/registry.hpp`'s existing style. +- All new C++ files use `// SPDX-License-Identifier: Apache-2.0` as the first line, matching every existing header/source in this repo. + +--- + +## Task 1: `ActionExecuteRegistry` in `bridge.hpp` with a direct unit test + +**Files:** +- Modify: `include/morph/bridge.hpp` (add `ActionExecuteRegistry` class and `defaultActionExecuteRegistry()` free function, near the top of `namespace morph::bridge`, before `class Bridge`) +- Modify: `include/morph/registry.hpp` (add `registerActionExecutorOnce` helper in `namespace morph::model::detail`, and one call to it inside `BRIDGE_REGISTER_ACTION_4`) +- Test: `tests/test_bridge_execute_json.cpp` (new) +- Modify: `tests/CMakeLists.txt` (add the new test file to `add_executable(morph_tests ...)`) + +**Interfaces:** +- Consumes: `morph::bridge::Bridge`, `morph::bridge::BridgeHandler::execute()` (existing, `include/morph/bridge.hpp:412-415`), `morph::model::ActionTraits::fromJson`/`resultToJson` (existing, generated by `BRIDGE_REGISTER_ACTION_4`), `morph::async::Completion` (existing, `include/morph/completion.hpp`). +- Produces: `morph::bridge::ActionExecuteRegistry` singleton with `execute(modelId, actionId, handlerVoid, bodyJson) -> Completion` (throws `std::runtime_error` if `(modelId, actionId)` is unregistered). `morph::model::detail::registerActionExecutorOnce(modelId, actionId)` — called automatically from `BRIDGE_REGISTER_ACTION_4`, not meant to be called by hand. + +Note on layering: `registry.hpp` is included by `bridge.hpp` today (`bridge.hpp:19` includes `"registry.hpp"`), so `bridge.hpp` can freely reference `morph::model::detail` types. `registry.hpp` must NOT include `bridge.hpp` back (would cycle) — `registerActionExecutorOnce` in `registry.hpp` calls into `morph::bridge::ActionExecuteRegistry::instance()` via a forward declaration plus an out-of-line definition placed in `bridge.hpp` after both classes are visible. See Step 3 for the exact split. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_bridge_execute_json.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace { + +struct AddNumbers { + int a = 0; + int b = 0; +}; + +struct AddResult { + int sum = 0; +}; + +struct MathModel { + AddResult execute(const AddNumbers& action) { return AddResult{.sum = action.a + action.b}; } +}; + +} // namespace + +BRIDGE_REGISTER_MODEL(MathModel, "Test_ExecJson_MathModel") +BRIDGE_REGISTER_ACTION(MathModel, AddNumbers, "Test_ExecJson_AddNumbers") + +using SyncExecutor = morph::testing::InlineExecutor; + +TEST_CASE("ActionExecuteRegistry: executeJson deserialises, executes, and re-serialises", "[bridge][execute-json]") { + morph::exec::ThreadPoolExecutor pool{2}; + SyncExecutor cbExec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &cbExec}; + + std::optional resultJson; + std::atomic done{false}; + handler.executeJson("Test_ExecJson_AddNumbers", R"({"a":3,"b":4})") + .then([&](std::string json) { + resultJson = std::move(json); + done.store(true); + }) + .onError([&](const std::exception_ptr&) { done.store(true); }); + + REQUIRE(morph::testing::waitUntil([&] { return done.load(); })); + REQUIRE(resultJson.has_value()); + REQUIRE(*resultJson == R"({"sum":7})"); +} + +TEST_CASE("ActionExecuteRegistry: executeJson reports parse errors via onError", "[bridge][execute-json]") { + morph::exec::ThreadPoolExecutor pool{2}; + SyncExecutor cbExec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &cbExec}; + + bool sawError = false; + std::atomic done{false}; + handler.executeJson("Test_ExecJson_AddNumbers", R"({"a":"not a number"})") + .then([&](std::string) { done.store(true); }) + .onError([&](const std::exception_ptr&) { + sawError = true; + done.store(true); + }); + + REQUIRE(morph::testing::waitUntil([&] { return done.load(); })); + REQUIRE(sawError); +} + +TEST_CASE("ActionExecuteRegistry: unknown action type throws", "[bridge][execute-json]") { + morph::exec::ThreadPoolExecutor pool{2}; + SyncExecutor cbExec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &cbExec}; + + REQUIRE_THROWS_AS(handler.executeJson("NoSuchAction", "{}"), std::runtime_error); +} +``` + +Check `tests/test_support.hpp` exposes `waitUntil` (confirmed present at `tests/test_support.hpp:40` as `template void waitUntil(Pred, budget = kDefaultWaitBudget)` — if the exact name differs, use whatever polling helper is already defined there instead of adding a new one). + +- [ ] **Step 2: Add the new test file to the test target** + +In `tests/CMakeLists.txt`, add `test_bridge_execute_json.cpp` to the `add_executable(morph_tests ...)` list (alphabetical-ish position doesn't matter — follow the existing loose grouping, e.g. right after `test_bridge_remote.cpp`): + +```cmake + test_bridge_local.cpp + test_bridge_remote.cpp + test_bridge_execute_json.cpp + test_remote_extra.cpp +``` + +- [ ] **Step 3: Run the test to verify it fails to compile** + +Run: `cmake --build build/cl-debug --target morph_tests` +Expected: FAIL — `'executeJson': is not a member of 'morph::bridge::BridgeHandler'` (or similar "no member" compiler error). If `build/cl-debug` does not exist yet, configure it first: `cmake --preset cl-debug` (requires `VCPKG_ROOT` set to the vcpkg install, e.g. `C:/Program Files/Microsoft Visual Studio/18/Professional/VC/vcpkg`). + +- [ ] **Step 4: Implement `ActionExecuteRegistry` in `bridge.hpp`** + +In `include/morph/bridge.hpp`, add right after the `namespace morph::bridge {` line and before `namespace detail {` (i.e. before line 24's `namespace detail {` block): + +```cpp +namespace morph::bridge { + +/// @brief Type-erased, JSON-in/JSON-out execute path for actions whose +/// concrete C++ type is only known by its registered string id at +/// the call site (e.g. a schema-driven GUI that reads action names +/// out of a JSON Schema at runtime). +/// +/// Populated automatically by `BRIDGE_REGISTER_ACTION` — no action-specific +/// code is required at any call site. Every entry calls through the real +/// `BridgeHandler::execute()` (so sessions, backend +/// switches, and completions all behave exactly as they do for hand-written +/// call sites), unlike `morph::model::detail::ActionDispatcher`, which +/// calls `Model::execute` directly against an already-owned model holder +/// and is only ever used server-side. +class ActionExecuteRegistry { +public: + /// @brief Deserialises `bodyJson`, dispatches through the handler's + /// `Bridge`, and resolves with the JSON-encoded result. + using Executor = std::function<::morph::async::Completion(void*, std::string_view)>; + + /// @brief Registers the executor for `(Model, Action)` under the given string ids. + template + void registerAction(std::string_view modelId, std::string_view actionId) { + Key key{std::string{modelId}, std::string{actionId}}; + _executors[key] = [](void* handlerVoid, std::string_view bodyJson) -> ::morph::async::Completion { + auto* handler = static_cast*>(handlerVoid); + auto resultState = std::make_shared<::morph::async::detail::CompletionState>(); + Action action = ::morph::model::ActionTraits::fromJson(bodyJson); + handler->template execute(std::move(action)) + .then([resultState](typename ::morph::model::ActionTraits::Result result) { + resultState->setValue(::morph::model::ActionTraits::resultToJson(result)); + }) + .onError([resultState](const std::exception_ptr& err) { resultState->setException(err); }); + return {resultState, handler->guiExecutor()}; + }; + } + + /// @brief Looks up and invokes the executor for `(modelId, actionId)`. + /// @throws std::runtime_error if no executor was registered for that pair. + [[nodiscard]] ::morph::async::Completion execute(std::string_view modelId, std::string_view actionId, + void* handler, std::string_view bodyJson) const { + auto iter = _executors.find(Key{std::string{modelId}, std::string{actionId}}); + if (iter == _executors.end()) { + throw std::runtime_error("unknown action for executeJson: " + std::string{modelId} + "/" + + std::string{actionId}); + } + return iter->second(handler, bodyJson); + } + + /// @brief Returns the process-level singleton registry. + static ActionExecuteRegistry& instance(); + +private: + using Key = std::pair; + struct KeyHash { + std::size_t operator()(const Key& key) const noexcept { + std::size_t seed = std::hash{}(key.first); + seed ^= std::hash{}(key.second) + 0x9e3779b9 + (seed << 6) + (seed >> 2); + return seed; + } + }; + std::unordered_map _executors; +}; + +inline ActionExecuteRegistry& ActionExecuteRegistry::instance() { + static ActionExecuteRegistry inst; + return inst; +} + +namespace detail { +``` + +This inserts the new class inside the existing `namespace morph::bridge { ... namespace detail { ... } }` structure — `ActionExecuteRegistry` sits in `morph::bridge` directly (sibling of `Bridge`, `BridgeHandler`), and the pre-existing `namespace detail { ... }` block (starting at what was line 24) continues unchanged right after it. + +`bridge.hpp` already includes ``, ``, ``, ``, ``, `` (see its existing include list, lines 4-15) and `"registry.hpp"` (line 19) — no new includes needed for this step. `` is also already included (line 10), covering `std::runtime_error`. + +- [ ] **Step 5: Add `BridgeHandler::executeJson` and `guiExecutor()`** + +In `include/morph/bridge.hpp`, inside `class BridgeHandler` (the existing class, currently ending around line 486-598), add two public methods right after the existing `execute` method (which ends at what is currently line 415, just before `subscribe`): + +```cpp + /// @brief Type-erased execute: looks up the action by its registered + /// string id and dispatches it through `ActionExecuteRegistry`. + /// + /// Use this only when the concrete `Action` type is not known at the + /// call site (e.g. a schema-driven UI reading action names out of a + /// JSON Schema at runtime). Prefer the templated `execute()` + /// whenever the type is known at compile time. + /// + /// @param actionType Registered action type-id (the `NAME` passed to `BRIDGE_REGISTER_ACTION`). + /// @param bodyJson JSON-encoded action body. + /// @return Completion resolving with the JSON-encoded result. + /// @throws std::runtime_error if `actionType` was never registered for `Model`. + [[nodiscard]] ::morph::async::Completion executeJson(std::string_view actionType, + std::string_view bodyJson) { + return ActionExecuteRegistry::instance().execute( + std::string{::morph::model::ModelTraits::typeId()}, actionType, this, bodyJson); + } + + /// @brief The executor used to deliver this handler's `Completion` callbacks. + /// @return The GUI/callback executor passed at construction. + [[nodiscard]] ::morph::exec::IExecutor* guiExecutor() const noexcept { return _guiExec; } +``` + +- [ ] **Step 6: Register the executor automatically inside `BRIDGE_REGISTER_ACTION_4`** + +In `include/morph/registry.hpp`, add a new detail helper right after `registerActionOnce` (currently lines 286-291): + +```cpp +/// @brief Static-init helper for `BRIDGE_REGISTER_ACTION`'s generic-execute registration. +/// +/// Defined out-of-line in `bridge.hpp` (after `ActionExecuteRegistry` is +/// visible) to avoid a `registry.hpp` -> `bridge.hpp` include cycle; +/// `bridge.hpp` already includes `registry.hpp`, not the other way round. +template +inline bool registerActionExecutorOnce(std::string_view modelId, std::string_view actionId) noexcept; +``` + +Then in `BRIDGE_REGISTER_ACTION_4` (currently lines 344-382), add one line to the generated `namespace { ... }` block, right after the existing `bridge_action_reg_##M##_##A` line: + +```cpp + namespace { \ + [[maybe_unused]] const bool bridge_action_reg_##M##_##A = \ + morph::model::detail::registerActionOnce(morph::model::ModelTraits::typeId(), NAME); \ + [[maybe_unused]] const bool bridge_action_exec_reg_##M##_##A = \ + morph::model::detail::registerActionExecutorOnce(morph::model::ModelTraits::typeId(), NAME); \ + } +``` + +Now, in `include/morph/bridge.hpp`, define the out-of-line body — add this right after the `ActionExecuteRegistry` class and its `instance()` definition (from Step 4), still before the pre-existing `namespace detail {` line: + +```cpp +} // namespace morph::bridge + +namespace morph::model::detail { + +template +inline bool registerActionExecutorOnce(std::string_view modelId, std::string_view actionId) noexcept { + ::morph::bridge::ActionExecuteRegistry::instance().registerAction(modelId, actionId); + return true; +} + +} // namespace morph::model::detail + +namespace morph::bridge { + +namespace detail { +``` + +(This briefly closes and reopens `namespace morph::bridge` around the `morph::model::detail` block — matching the file's existing style of nested `namespace X { ... }` blocks rather than fully-qualified names for a multi-line definition.) + +**Important ordering constraint:** `registry.hpp`'s declaration of `registerActionExecutorOnce` (a template) only needs to be *declared* before `BRIDGE_REGISTER_ACTION_4` expands (macro text is checked at the point of use, i.e. wherever `BRIDGE_REGISTER_ACTION` is called in user code, which always includes `bridge.hpp` transitively — confirm this holds for `lab_model.hpp`: it includes `` directly per its current include list, and any translation unit calling `BRIDGE_REGISTER_ACTION` that also wants `Bridge`/`BridgeHandler` already includes `bridge.hpp`). Since `registry.hpp` only needs the *declaration* (function bodies are resolved at link time / are `inline`), and `bridge.hpp` includes `registry.hpp`, the definition in `bridge.hpp` is visible to any TU that includes `bridge.hpp` — which every `BRIDGE_REGISTER_ACTION` call site in this plan's scope (`lab_model.hpp`, the new test file) already does or will do. If a future action file calls `BRIDGE_REGISTER_ACTION` without including `bridge.hpp`, it will fail to link (undefined reference to `registerActionExecutorOnce`) — note this constraint in the doc comment on `registerActionExecutorOnce`'s declaration in `registry.hpp` (already drafted above) and additionally confirm in Step 7 that `lab_model.hpp` needs no include changes because `FormsController.cpp` (Task 3) will include `bridge.hpp` in the same binary. + +- [ ] **Step 7: Build and run the test** + +Run: `cmake --build build/cl-debug --target morph_tests` +Expected: builds cleanly. + +Run: `ctest --test-dir build/cl-debug -R "execute-json" --output-on-failure` +Expected: all 3 new test cases PASS. + +- [ ] **Step 8: Run the full existing test suite to check for regressions** + +Run: `ctest --test-dir build/cl-debug --output-on-failure` +Expected: all tests pass (same pass count as before this change, plus the 3 new ones). This exercises every existing `BRIDGE_REGISTER_ACTION` call site in the test suite, confirming the macro change is backward compatible. + +- [ ] **Step 9: Commit** + +```bash +git add include/morph/bridge.hpp include/morph/registry.hpp tests/test_bridge_execute_json.cpp tests/CMakeLists.txt +git commit -m "$(cat <<'EOF' +feat: add ActionExecuteRegistry for JSON-in/JSON-out action dispatch + +BridgeHandler::executeJson(actionType, bodyJson) resolves a runtime +action-type string to the real compile-time execute() call, +registered automatically inside BRIDGE_REGISTER_ACTION. Lets a +schema-driven UI dispatch through Bridge/BridgeHandler without knowing +concrete C++ action types at compile time, with zero new declarations +per action. +EOF +)" +``` + +--- + +## Task 2: Rewrite `FormsController` to use `Bridge`/`BridgeHandler` + +**Files:** +- Modify: `examples/forms/gui_qml/FormsController.hpp` +- Modify: `examples/forms/gui_qml/FormsController.cpp` + +**Interfaces:** +- Consumes: `morph::bridge::Bridge`, `morph::bridge::BridgeHandler::executeJson` (Task 1), `morph::bridge::BridgeHandler::execute()` (existing), `morph::backend::LocalBackend` (existing, `include/morph/backend.hpp:148-152`), `morph::qt::QtExecutor` (existing, `include/morph/qt/qt_executor.hpp`). +- Produces: `FormsController` with the same public signal surface as before (`replyReceived(actionType, ok, payload)`, `optionsReceived(optionsAction, ok, payload)`, `schemasJson` property) plus a renamed invokable `submitIfValid(actionType, bodyJson)` replacing `submit(actionType, bodyJson)`. `fetchOptions(optionsAction)` keeps its existing signature. + +- [ ] **Step 1: Update `FormsController.hpp`** + +Replace the full contents of `examples/forms/gui_qml/FormsController.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// QML-facing controller for the schema-driven forms demo. +/// +/// The QML layer renders forms from the schemas exposed here and submits +/// fully-assembled action bodies as JSON strings via `submitIfValid`, +/// called on every edit once the body validates client-side (no submit +/// button). This controller dispatches through a real `morph::bridge::Bridge` +/// + `BridgeHandler` — the same client API `examples/bank`'s GUI +/// uses — via the generic `BridgeHandler::executeJson` path, so it never +/// touches `morph::wire::Envelope` or `morph::backend::RemoteServer` +/// directly. + +#include +#include +#include + +// Guarded like examples/bank/gui/controllers/AccountController.hpp: MOC +// only needs the Q_OBJECT/QML_ELEMENT macros above and the Q_INVOKABLE/ +// Q_PROPERTY declarations below; it must not be pointed at morph's +// template-heavy headers (bridge.hpp, glaze) or the Qt executor. +#ifndef Q_MOC_RUN +#include +#include +#include + +#include "lab_model.hpp" +#endif + +class FormsController : public QObject { + Q_OBJECT + QML_ELEMENT + + /// @brief `{actionType: schema}` JSON — everything the QML renderer needs. + Q_PROPERTY(QString schemasJson READ schemasJson CONSTANT) + +public: + explicit FormsController(QObject* parent = nullptr); + + [[nodiscard]] QString schemasJson() const; + + /// @brief Dispatches @p bodyJson as the body of @p actionType if the + /// body is complete. Called by QML on every field edit once the + /// assembled body passes client-side validation — there is no + /// separate submit step. The reply arrives via `replyReceived` + /// on the GUI thread. + Q_INVOKABLE void submitIfValid(const QString& actionType, const QString& bodyJson); + + /// @brief Executes @p optionsAction with an empty body to fetch combo-box + /// options (a `Choice` field's declared provider). The reply + /// arrives via `optionsReceived` on the GUI thread. + Q_INVOKABLE void fetchOptions(const QString& optionsAction); + +signals: + /// @brief Emitted once per `submitIfValid` call. @p payload is the + /// result JSON when @p ok, otherwise the error message. + void replyReceived(const QString& actionType, bool ok, const QString& payload); + + /// @brief Emitted once per `fetchOptions`. @p payload is the options + /// action's result JSON when @p ok, otherwise the error message. + void optionsReceived(const QString& optionsAction, bool ok, const QString& payload); + +private: + // Declaration order matters for destruction: `_handler` and `_bridge` + // must be torn down before `_pool`/`_gui`, and `_pool` must outlive the + // `LocalBackend` owned inside `_bridge` (constructed from it). Declared + // in construction order so default destruction (reverse order) is safe. + morph::exec::ThreadPoolExecutor _pool{2}; + morph::qt::QtExecutor _gui; + morph::bridge::Bridge _bridge; + morph::bridge::BridgeHandler _handler; +}; +``` + +- [ ] **Step 2: Update `FormsController.cpp`** + +Replace the full contents of `examples/forms/gui_qml/FormsController.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "FormsController.hpp" + +#include +#include +#include + +#include + +#include "lab_schemas.hpp" + +namespace { + +QString errorText(const std::exception_ptr& err) { + try { + if (err) { + std::rethrow_exception(err); + } + } catch (const std::exception& exc) { + return QString::fromUtf8(exc.what()); + } catch (...) { + return QStringLiteral("unknown error"); + } + return {}; +} + +} // namespace + +FormsController::FormsController(QObject* parent) + : QObject{parent}, + _bridge{std::make_unique(_pool)}, + _handler{_bridge, &_gui} {} + +QString FormsController::schemasJson() const { + return QString::fromStdString(lab::schemasJson()); +} + +void FormsController::submitIfValid(const QString& actionType, const QString& bodyJson) { + auto const actionTypeStd = actionType.toStdString(); + _handler.executeJson(actionTypeStd, bodyJson.toStdString()) + .then([this, actionType](std::string resultJson) { + emit replyReceived(actionType, true, QString::fromStdString(resultJson)); + }) + .onError([this, actionType](const std::exception_ptr& err) { + emit replyReceived(actionType, false, errorText(err)); + }); +} + +void FormsController::fetchOptions(const QString& optionsAction) { + _handler.execute(lab::ListSamples{}) + .then([this, optionsAction](lab::SampleList list) { + std::string json = lab::ActionTraits_SampleList_toJson(list); + emit optionsReceived(optionsAction, true, QString::fromStdString(json)); + }) + .onError([this, optionsAction](const std::exception_ptr& err) { + emit optionsReceived(optionsAction, false, errorText(err)); + }); +} +``` + +The `fetchOptions` body above has a placeholder call (`lab::ActionTraits_SampleList_toJson`) that does not exist — fix it in the next step by using the registered `ActionTraits` codec directly, since `SampleList` (the result of `ListSamples`) is not itself a registered action type and has no `BRIDGE_REGISTER_ACTION`-generated `toJson`. Use `glz::write_json` directly instead: + +Replace the `fetchOptions` body with: + +```cpp +void FormsController::fetchOptions(const QString& optionsAction) { + _handler.execute(lab::ListSamples{}) + .then([this, optionsAction](lab::SampleList list) { + std::string json; + if (glz::write_json(list, json)) { + emit optionsReceived(optionsAction, false, QStringLiteral("failed to encode options")); + return; + } + emit optionsReceived(optionsAction, true, QString::fromStdString(json)); + }) + .onError([this, optionsAction](const std::exception_ptr& err) { + emit optionsReceived(optionsAction, false, errorText(err)); + }); +} +``` + +This needs `#include ` added to the top of `FormsController.cpp`'s include list (alongside the others). + +**Note on `optionsJson()`'s shape:** the previous `fetchOptions` (via `RemoteServer`) returned the result of executing `ListSamples` wrapped as `{"samples": [...]}` (matching `lab::SampleList`'s own field name) — `glz::write_json(list, json)` produces exactly the same shape (`{"samples":[{"id":1,"name":"Proctor A"}, ...]}`), so `DynamicForm.qml`'s `optionRows()` helper (which already handles "the result itself when it is an array, otherwise its first array-valued member" per `qml/DynamicForm.qml:236-244`) needs no change. + +- [ ] **Step 3: Build** + +Run: `cmake --build build/qml --target morph_forms_qml` (reuse the `build/qml` configure directory from the earlier `MORPH_BUILD_FORMS_QML=ON` work — if it no longer exists, reconfigure: `cmake -S . -B build/qml -G Ninja -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" -DMORPH_BUILD_FORMS_QML=ON -DCMAKE_BUILD_TYPE=Debug`) +Expected: builds cleanly. If it fails on `_handler{_bridge, &_gui}` member-initializer-list ordering (declaration order in the header must match: `_pool`, `_gui`, `_bridge`, `_handler` — already specified that way in Step 1), fix the header's member order to match and rebuild. + +- [ ] **Step 4: Manual smoke test** + +Run: `./build/qml/examples/forms/gui_qml/morph_forms_qml.exe` (add `$env:PATH` prefix with the Qt bin dir, or rely on the `windeployqt` post-build step from the earlier CMake work if already applied) and confirm the window opens with no crash and the forms render (they will not yet be reactive — the QML side is updated in Task 3). Close the window (or `Ctrl+C` the process) once confirmed. + +- [ ] **Step 5: Commit** + +```bash +git add examples/forms/gui_qml/FormsController.hpp examples/forms/gui_qml/FormsController.cpp +git commit -m "$(cat <<'EOF' +refactor: FormsController dispatches through Bridge/BridgeHandler + +Replaces hand-built wire::Envelope + RemoteServer plumbing with the +framework's real client API, matching how examples/bank's GUI talks to +its models. submit() is renamed submitIfValid() and now goes through +BridgeHandler::executeJson (added in the previous commit) instead of a +manually assembled envelope. +EOF +)" +``` + +--- + +## Task 3: Make `DynamicForm.qml` reactive (remove the submit button) + +**Files:** +- Modify: `examples/forms/gui_qml/qml/DynamicForm.qml` + +**Interfaces:** +- Consumes: `FormsController::submitIfValid(actionType, bodyJson)` (Task 2), existing `replyReceived`/`optionsReceived` signals (unchanged shape). +- Produces: no new public interface — this is a leaf QML component change. + +- [ ] **Step 1: Change `revalidate()` to call `submitIfValid` when the form is ready** + +In `examples/forms/gui_qml/qml/DynamicForm.qml`, find the `revalidate()` function (currently lines 180-227). At the end, where it currently sets: + +```qml + ready = ok + previewLine = ok ? "{" + parts.join(",") + "}" : "" +``` + +replace with: + +```qml + ready = ok + previewLine = ok ? "{" + parts.join(",") + "}" : "" + if (ready && form.controller) + form.controller.submitIfValid(form.actionType, form.previewLine) +``` + +- [ ] **Step 2: Remove the submit button from the reactive path** + +Find the `Button` element (currently lines 384-389): + +```qml + Button { + Layout.topMargin: 8 + text: "execute" + enabled: form.ready + onClicked: form.controller.submit(form.actionType, form.previewLine) + } +``` + +Replace it with a plain status label (keeps the layout stable, communicates the new reactive behavior instead of a dead control): + +```qml + Label { + Layout.topMargin: 8 + text: form.ready ? "✓ executes automatically as you type" : "fill the required (*) fields" + opacity: 0.6 + font.italic: true + } +``` + +- [ ] **Step 3: Build** + +Run: `cmake --build build/qml --target morph_forms_qml` +Expected: builds cleanly (QML changes do not require a C++ recompile, but re-running the build re-runs `qt_add_qml_module`'s QML resource packaging so the updated `.qml` file is picked up). + +- [ ] **Step 4: Manual smoke test — verify reactivity** + +Run: `./build/qml/examples/forms/gui_qml/morph_forms_qml.exe`. In the `ComputeDryDensity` form, type a value into `massDry`, then `volume`. Confirm: +- No "execute" button is present; the italic status label toggles from "fill the required fields" to "executes automatically as you type" once both fields have valid values. +- `resultText` updates automatically (no click needed) once both required fields are filled. +- Editing `volume` again after a result already appeared re-fires and updates `resultText` again without any click. + +Close the app once confirmed. + +- [ ] **Step 5: Commit** + +```bash +git add examples/forms/gui_qml/qml/DynamicForm.qml +git commit -m "$(cat <<'EOF' +feat: fire forms automatically on valid edit, remove submit button + +DynamicForm.qml now calls controller.submitIfValid() directly from +revalidate() whenever the assembled body passes client-side checks, +instead of waiting for a button click. Matches the live-recompute UX +the framework's fielded-action API models, without requiring a +compile-time set<&Action::field> hook per field. +EOF +)" +``` + +--- + +## Task 4: Qt Quick Test coverage for the reactive path + +**Files:** +- Modify: `examples/forms/gui_qml/tests/tst_main.cpp` (check its current structure first — it likely just registers the test directory; see Step 1) +- Create: `examples/forms/gui_qml/tests/tst_DynamicFormReactive.qml` + +**Interfaces:** +- Consumes: `DynamicForm.qml` (Task 3), a mock controller object exposing `submitIfValid`/`replyReceived`/`fetchOptions`/`optionsReceived` with the same signatures as `FormsController` (Qt Quick Test convention: a small QML mock, not the real C++ `FormsController`, so the test does not need a real `Bridge`/`LabModel` wired up). + +- [ ] **Step 1: Read the existing Quick Test setup** + +Run: `cat examples/forms/gui_qml/tests/tst_main.cpp` and list `examples/forms/gui_qml/tests/` to see what `.qml` test files already exist and what naming convention they use (Qt Quick Test auto-discovers `tst_*.qml` files in the directory passed via `-input` — confirmed by `gui_qml/CMakeLists.txt:42-45`: `add_test(... COMMAND morph_forms_qml_tests -input ${CMAKE_CURRENT_SOURCE_DIR}/tests)`). + +- [ ] **Step 2: Write the failing QML test** + +Create `examples/forms/gui_qml/tests/tst_DynamicFormReactive.qml` (adjust the mock controller's property/signal shape to match whatever pattern the existing test files in this directory already use, once Step 1's inspection is done — if this is the first QML test file in the suite, use the shape below): + +```qml +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtTest +import MorphForms + +TestCase { + id: testCase + name: "DynamicFormReactive" + + QtObject { + id: mockController + signal replyReceived(string actionType, bool ok, string payload) + signal optionsReceived(string optionsAction, bool ok, string payload) + + property int submitCount: 0 + property string lastActionType: "" + property string lastBodyJson: "" + + function submitIfValid(actionType, bodyJson) { + submitCount += 1 + lastActionType = actionType + lastBodyJson = bodyJson + replyReceived(actionType, true, JSON.stringify({sum: 42})) + } + + function fetchOptions(optionsAction) { + optionsReceived(optionsAction, true, "[]") + } + } + + property var testSchema: ({ + properties: { + a: { type: "integer", "x-order": 0 }, + b: { type: "integer", "x-order": 1 } + }, + required: ["a", "b"] + }) + + Component { + id: formComponent + DynamicForm { + actionType: "TestAction" + schema: testCase.testSchema + controller: mockController + } + } + + function test_fires_automatically_without_button() { + mockController.submitCount = 0 + var form = createTemporaryObject(formComponent, testCase) + verify(form !== null) + + // Locate the two TextField inputs in declaration order and type + // into them — DynamicForm has no button in the reactive path. + var fields = [] + function collect(item) { + for (var i = 0; i < item.children.length; ++i) { + var child = item.children[i] + if (child.hasOwnProperty("text") && child.toString().indexOf("TextField") !== -1) + fields.push(child) + collect(child) + } + } + collect(form) + compare(fields.length, 2) + + fields[0].text = "3" + fields[1].text = "4" + + compare(mockController.submitCount, 1) + compare(mockController.lastActionType, "TestAction") + compare(form.resultText !== "", true) + } + + function test_does_not_fire_when_incomplete() { + mockController.submitCount = 0 + var form = createTemporaryObject(formComponent, testCase) + verify(form !== null) + + var fields = [] + function collect(item) { + for (var i = 0; i < item.children.length; ++i) { + var child = item.children[i] + if (child.hasOwnProperty("text") && child.toString().indexOf("TextField") !== -1) + fields.push(child) + collect(child) + } + } + collect(form) + + fields[0].text = "3" + compare(mockController.submitCount, 0) + } +} +``` + +If the DOM-walk-based field lookup (`collect()`) proves brittle against `DynamicForm`'s actual visual tree (Qt Quick Test's `findChild`-by-objectName is more standard), add `objectName: "field_" + fieldColumn.modelData.name` to the `TextField` in `qml/DynamicForm.qml` (inside the `Repeater`'s delegate, on the `TextField id: entry` element, currently around line 341-351) and use `findChild(form, "field_a")`/`findChild(form, "field_b")` in the test instead — this is the more idiomatic Qt Quick Test approach and should be preferred; only fall back to the manual `collect()` walk if `findChild` is unavailable in the project's Qt version. Prefer adding the `objectName` and using `findChild`. + +Revised field-lookup approach (use this instead of `collect()`): + +In `qml/DynamicForm.qml`, on the `TextField` (currently starting at line 341): + +```qml + TextField { + id: entry + objectName: "field_" + fieldColumn.modelData.name + visible: !fieldColumn.modelData.isChoice && !fieldColumn.modelData.isDateTime + ... +``` + +And in the test, replace the `collect()`-based lookup with: + +```qml + function test_fires_automatically_without_button() { + mockController.submitCount = 0 + var form = createTemporaryObject(formComponent, testCase) + verify(form !== null) + + var fieldA = findChild(form, "field_a") + var fieldB = findChild(form, "field_b") + verify(fieldA !== null) + verify(fieldB !== null) + + fieldA.text = "3" + fieldB.text = "4" + + compare(mockController.submitCount, 1) + compare(mockController.lastActionType, "TestAction") + compare(form.resultText !== "", true) + } + + function test_does_not_fire_when_incomplete() { + mockController.submitCount = 0 + var form = createTemporaryObject(formComponent, testCase) + verify(form !== null) + + var fieldA = findChild(form, "field_a") + verify(fieldA !== null) + fieldA.text = "3" + compare(mockController.submitCount, 0) + } +``` + +- [ ] **Step 3: Add the `objectName` to `DynamicForm.qml`** + +Apply the `objectName: "field_" + fieldColumn.modelData.name` addition to the `TextField` shown in Step 2 above. + +- [ ] **Step 4: Run the test to verify it fails first (TDD check), then passes** + +Run: `cmake --build build/qml --target morph_forms_qml_tests` +Then: `ctest --test-dir build/qml -R forms_qml_logic --output-on-failure` + +Expected on first run (before Step 3's `objectName` addition is in place, if done out of order): `findChild` returns `null`, test fails on `verify(fieldA !== null)`. After Step 3 is applied: both tests PASS. + +- [ ] **Step 5: Run the full forms QML test suite for regressions** + +Run: `ctest --test-dir build/qml -R forms --output-on-failure` +Expected: `forms_qml_logic` and `forms_html_math` (if `node` is on `PATH`) both PASS — confirms the `objectName` addition and `revalidate()` change did not break any existing QML logic test. + +- [ ] **Step 6: Commit** + +```bash +git add examples/forms/gui_qml/qml/DynamicForm.qml examples/forms/gui_qml/tests/tst_DynamicFormReactive.qml +git commit -m "$(cat <<'EOF' +test: cover DynamicForm's auto-fire-on-valid behavior + +Adds a Qt Quick Test driving DynamicForm through simulated field edits +against a mock controller, asserting submitIfValid fires exactly once +the form becomes valid and not before — without any button click. +EOF +)" +``` + +--- + +## Task 5: Update the forms demo README + +**Files:** +- Modify: `examples/forms/README.md` + +**Interfaces:** None — documentation only. + +- [ ] **Step 1: Read the current README** + +Run: `cat examples/forms/README.md` to see its current description of the GUI's architecture (it likely still describes the `RemoteServer`/wire-envelope approach, matching the old `FormsController.hpp` file-header comment that said "wraps them in `morph::wire::Envelope`s and feeds them to an in-process `RemoteServer`"). + +- [ ] **Step 2: Update the architecture description** + +Find and replace any prose describing `FormsController` as wrapping actions in `wire::Envelope`s / talking to `RemoteServer` with a description matching the new design: `FormsController` owns a `Bridge` + `BridgeHandler` and dispatches via the generic `executeJson` path, exactly like `examples/bank`'s GUI controllers. Also add a short note that forms execute automatically once valid (no submit button), replacing any mention of a submit/execute button in the QML section. + +Since the exact current wording is only knowable by reading the file first (Step 1), the edit itself is: locate the paragraph(s) describing the GUI's transport and button-driven flow, and rewrite them to state: + +> The Qt Quick renderer (`gui_qml/`) dispatches actions through `morph::bridge::Bridge` + `BridgeHandler` — the same client API `examples/bank`'s GUI uses — via a generic, JSON-in/JSON-out `executeJson` path (see `BridgeHandler::executeJson` in `morph/bridge.hpp`). Forms have no submit button: each form fires automatically the moment its assembled body passes client-side validation, and re-fires on every subsequent edit. Because each keystroke can trigger a fresh dispatch with no coalescing, rapid edits may produce out-of-order replies for the same action (last-arrival wins on the displayed result) — acceptable for this demo's read-mostly compute actions, but worth knowing before reusing this pattern for actions with side effects. + +- [ ] **Step 3: Commit** + +```bash +git add examples/forms/README.md +git commit -m "$(cat <<'EOF' +docs: describe the Bridge/BridgeHandler-based reactive forms flow + +Updates the forms demo README to match FormsController's new transport +(Bridge/BridgeHandler via executeJson) and UX (auto-fire on valid edit, +no submit button), replacing the outdated RemoteServer/wire::Envelope +description. +EOF +)" +``` + +--- + +## Task 6: Full-suite regression pass and manual end-to-end verification + +**Files:** None modified — verification only. + +**Interfaces:** None — this task consumes everything built in Tasks 1-5. + +- [ ] **Step 1: Full native test suite** + +Run: `ctest --test-dir build/cl-debug --output-on-failure` +Expected: 100% pass (same as the baseline before this plan, plus the 3 new cases from Task 1). + +- [ ] **Step 2: Full Qt-enabled test suite (WebSocket backend, unrelated to this change but shares the `Bridge`/`bridge.hpp` header)** + +Run: `ctest --test-dir build/cl-qt-debug --output-on-failure` +Expected: same pass rate as the pre-existing baseline (365/366, with the one known pre-existing failure being an unrelated shell/em-dash encoding issue in a test filter name — see prior session notes). This confirms the `bridge.hpp` changes from Task 1 do not regress the Qt WebSocket backend, which also instantiates `BridgeHandler`. + +- [ ] **Step 3: Full QML forms test suite** + +Run: `ctest --test-dir build/qml -R forms --output-on-failure` +Expected: 100% pass, including the new `test_fires_automatically_without_button`/`test_does_not_fire_when_incomplete` cases from Task 4. + +- [ ] **Step 4: Manual end-to-end smoke test of the real app** + +Run: `./build/qml/examples/forms/gui_qml/morph_forms_qml.exe`. Walk through: +1. `ComputeDryDensity`: type `massDry` and `volume` values; confirm density computes and displays automatically, no click. +2. `RecordMeasurement`: confirm the `sampleId` combo box populates from `ListSamples` (via `fetchOptions`, unchanged path); fill `sampleId`, `measuredAt` (use the "now" button), and `density`; confirm it fires automatically once all required fields are set and `moisture`/`note` remain optional. +3. Trigger a validation error deliberately (e.g. if possible, clear a required field after a successful fire) and confirm the app does not crash and `resultText`/error display behaves sensibly. + +Close the app once all three are confirmed. + +- [ ] **Step 5: Report completion** + +No commit for this task (verification only) — if all steps pass, the branch is ready for the `superpowers:requesting-code-review` or `superpowers:finishing-a-development-branch` skill, at the user's discretion. + +--- + +## Self-Review Notes + +- **Spec coverage:** Goals — "Bridge/BridgeHandler only" (Tasks 2-3), "fires automatically, no submit button" (Task 3), "zero new boilerplate per action" (Task 1's registration lives entirely inside `BRIDGE_REGISTER_ACTION_4`; `lab_model.hpp` is never touched in this plan), "`main.cpp` untouched" (no task modifies it) — all covered. Non-goals (`schemaJson` shape, `execute()`'s existing API, options-fetch reactivity, `--emit-html`) — none touched by any task. Testing section's three items (unit test for the registry, QML Quick Test, existing CTest wiring) map to Tasks 1 and 4 respectively. +- **Placeholder scan:** The one placeholder-like moment (Task 2 Step 2's first draft calling a nonexistent `lab::ActionTraits_SampleList_toJson`) is deliberately shown and then corrected within the same step with real code (`glz::write_json`), not left as a TBD — kept it in to document *why* the naive approach doesn't work (no `BRIDGE_REGISTER_ACTION` for `SampleList` itself), matching how the spec's own investigation-and-rejection of `set<>` was written up. +- **Type consistency:** `executeJson` signature (`std::string_view actionType, std::string_view bodyJson) -> Completion`) is identical across Task 1 Step 5 (definition) and Task 2 Step 2 (call site). `submitIfValid` signature matches between Task 2 Step 1 (`.hpp` declaration) and Step 2 (`.cpp` definition) and Task 3 Step 1 (QML call site) and Task 4's mock (test double). `ActionExecuteRegistry::instance()`/`execute()`/`registerAction()` names match between Task 1 Step 4 (class body) and Step 5/6 (call sites). From 30b8c2e1e3dde6b06edc2d70de06e12ff3970f23 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 6 Jul 2026 13:32:39 +0200 Subject: [PATCH 04/13] fix: auto-detect Qt for MORPH_BUILD_FORMS_QML and deploy its runtime DLLs FindQt.cmake was only included under MORPH_BUILD_QT, so enabling MORPH_BUILD_FORMS_QML alone failed to find Qt6 on Windows. Also adds a windeployqt post-build step so morph_forms_qml.exe can run standalone without Qt's bin directory on PATH. Co-Authored-By: Claude Sonnet 5 --- CMakeLists.txt | 9 ++++++--- examples/forms/gui_qml/CMakeLists.txt | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a57ba71..8f809f3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,12 @@ endif() option(MORPH_BUILD_QT "Build Qt6 WebSocket backend and tests" OFF) option(MORPH_BUILD_DOCUMENTATION "Build doxygen docs" OFF) +# Both Qt consumers (the WebSocket backend and the forms QML renderer) need +# Qt6 auto-detected on Windows before their find_package(Qt6 ...) calls run. +if(WIN32 AND (MORPH_BUILD_QT OR MORPH_BUILD_FORMS_QML)) + include(cmake/FindQt.cmake) +endif() + include(cmake/compiler_options.cmake) # Registered before any subdirectory so demo-level add_test calls (examples/ @@ -145,9 +151,6 @@ endif() # ── Qt WebSocket backend (optional) ───────────────────────────────────────── if(MORPH_BUILD_QT) - if(WIN32) - include(cmake/FindQt.cmake) - endif() find_package(Qt6 COMPONENTS WebSockets REQUIRED) # Header-only interface for consumers diff --git a/examples/forms/gui_qml/CMakeLists.txt b/examples/forms/gui_qml/CMakeLists.txt index 51f3e68..8f58b82 100644 --- a/examples/forms/gui_qml/CMakeLists.txt +++ b/examples/forms/gui_qml/CMakeLists.txt @@ -31,6 +31,26 @@ target_compile_features(morph_forms_module PUBLIC cxx_std_23) qt_add_executable(morph_forms_qml main.cpp) target_link_libraries(morph_forms_qml PRIVATE morph_forms_moduleplugin Qt6::Quick) +# Deploy Qt's runtime DLLs next to the executable so it can be launched +# directly (double-click, plain `./morph_forms_qml.exe`) without the Qt bin +# directory already on PATH. +if(WIN32) + find_program(MORPH_WINDEPLOYQT_EXECUTABLE windeployqt + HINTS "${QT6_INSTALL_PREFIX}/bin" "${Qt6_DIR}/../../../bin" + ) + if(MORPH_WINDEPLOYQT_EXECUTABLE) + add_custom_command(TARGET morph_forms_qml POST_BUILD + COMMAND "${MORPH_WINDEPLOYQT_EXECUTABLE}" + --qmldir "${CMAKE_CURRENT_SOURCE_DIR}/qml" + $ + COMMENT "Deploying Qt runtime DLLs next to morph_forms_qml.exe" + ) + else() + message(WARNING "windeployqt not found; morph_forms_qml.exe will need " + "Qt's bin directory on PATH to run.") + endif() +endif() + # Qt Quick Test suite for the QML logic (DynamicForm descriptors, exact # arithmetic, unit conversion, composition, readiness). if(MORPH_BUILD_TESTS) From c4010a2df4416e6d25f36bcdd3b212da77838145 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 6 Jul 2026 13:40:17 +0200 Subject: [PATCH 05/13] feat: add ActionExecuteRegistry for JSON-in/JSON-out action dispatch BridgeHandler::executeJson(actionType, bodyJson) resolves a runtime action-type string to the real compile-time execute() call, registered automatically inside BRIDGE_REGISTER_ACTION. Lets a schema-driven UI dispatch through Bridge/BridgeHandler without knowing concrete C++ action types at compile time, with zero new declarations per action. Changes: - Add ActionExecuteRegistry singleton to bridge.hpp with string->executor mapping - Add BridgeHandler::executeJson() and ::guiExecutor() methods - Hook registerActionExecutorOnce into BRIDGE_REGISTER_ACTION_4 macro - Add 3 test cases for successful dispatch, parse error handling, and unknown action - Update test files to include bridge.hpp (now required by macro change) All 352 tests pass (3 new, 349 existing regressions = 0). --- examples/forms/main.cpp | 2 + include/morph/bridge.hpp | 113 +++++++++++++++++++++++++++++ include/morph/registry.hpp | 10 +++ tests/CMakeLists.txt | 1 + tests/test_bridge_execute_json.cpp | 81 +++++++++++++++++++++ tests/test_quantity_forms.cpp | 1 + tests/test_server_limits.cpp | 1 + 7 files changed, 209 insertions(+) create mode 100644 tests/test_bridge_execute_json.cpp diff --git a/examples/forms/main.cpp b/examples/forms/main.cpp index 8d3964a..76d2edc 100644 --- a/examples/forms/main.cpp +++ b/examples/forms/main.cpp @@ -14,6 +14,8 @@ /// the REPL — which dispatches it through the same type-erased /// `ActionDispatcher` seam `RemoteServer` uses — and read the model's reply. +#include + #include #include #include diff --git a/include/morph/bridge.hpp b/include/morph/bridge.hpp index 1ae3f6a..8aede77 100644 --- a/include/morph/bridge.hpp +++ b/include/morph/bridge.hpp @@ -21,6 +21,75 @@ namespace morph::bridge { +/// @brief Type-erased, JSON-in/JSON-out execute path for actions whose +/// concrete C++ type is only known by its registered string id at +/// the call site (e.g. a schema-driven GUI that reads action names +/// out of a JSON Schema at runtime). +/// +/// Populated automatically by `BRIDGE_REGISTER_ACTION` — no action-specific +/// code is required at any call site. Every entry calls through the real +/// `BridgeHandler::execute()` (so sessions, backend +/// switches, and completions all behave exactly as they do for hand-written +/// call sites), unlike `morph::model::detail::ActionDispatcher`, which +/// calls `Model::execute` directly against an already-owned model holder +/// and is only ever used server-side. +class ActionExecuteRegistry { +public: + /// @brief Deserialises `bodyJson`, dispatches through the handler's + /// `Bridge`, and resolves with the JSON-encoded result. + using Executor = std::function<::morph::async::Completion(void*, std::string_view)>; + + /// @brief Registers the executor for `(Model, Action)` under the given string ids. + /// Defined out-of-line after BridgeHandler to avoid forward reference issues. + template + void registerAction(std::string_view modelId, std::string_view actionId); + + /// @brief Looks up and invokes the executor for `(modelId, actionId)`. + /// @throws std::runtime_error if no executor was registered for that pair. + [[nodiscard]] ::morph::async::Completion execute(std::string_view modelId, std::string_view actionId, + void* handler, std::string_view bodyJson) const { + auto iter = _executors.find(Key{std::string{modelId}, std::string{actionId}}); + if (iter == _executors.end()) { + throw std::runtime_error("unknown action for executeJson: " + std::string{modelId} + "/" + + std::string{actionId}); + } + return iter->second(handler, bodyJson); + } + + /// @brief Returns the process-level singleton registry. + static ActionExecuteRegistry& instance(); + +private: + using Key = std::pair; + struct KeyHash { + std::size_t operator()(const Key& key) const noexcept { + std::size_t seed = std::hash{}(key.first); + seed ^= std::hash{}(key.second) + 0x9e3779b9 + (seed << 6) + (seed >> 2); + return seed; + } + }; + std::unordered_map _executors; +}; + +inline ActionExecuteRegistry& ActionExecuteRegistry::instance() { + static ActionExecuteRegistry inst; + return inst; +} + +} // namespace morph::bridge + +namespace morph::model::detail { + +template +inline bool registerActionExecutorOnce(std::string_view modelId, std::string_view actionId) noexcept { + ::morph::bridge::ActionExecuteRegistry::instance().registerAction(modelId, actionId); + return true; +} + +} // namespace morph::model::detail + +namespace morph::bridge { + namespace detail { /// @brief Compile-time decomposition of a pointer-to-data-member type. @@ -414,6 +483,28 @@ class BridgeHandler { return _bridge.template executeVia(_binding, std::move(action), _guiExec); } + /// @brief Type-erased execute: looks up the action by its registered + /// string id and dispatches it through `ActionExecuteRegistry`. + /// + /// Use this only when the concrete `Action` type is not known at the + /// call site (e.g. a schema-driven UI reading action names out of a + /// JSON Schema at runtime). Prefer the templated `execute()` + /// whenever the type is known at compile time. + /// + /// @param actionType Registered action type-id (the `NAME` passed to `BRIDGE_REGISTER_ACTION`). + /// @param bodyJson JSON-encoded action body. + /// @return Completion resolving with the JSON-encoded result. + /// @throws std::runtime_error if `actionType` was never registered for `Model`. + [[nodiscard]] ::morph::async::Completion executeJson(std::string_view actionType, + std::string_view bodyJson) { + return ActionExecuteRegistry::instance().execute( + std::string{::morph::model::ModelTraits::typeId()}, actionType, this, bodyJson); + } + + /// @brief The executor used to deliver this handler's `Completion` callbacks. + /// @return The GUI/callback executor passed at construction. + [[nodiscard]] ::morph::exec::IExecutor* guiExecutor() const noexcept { return _guiExec; } + /// @brief Subscribes to results of action type @p Action. /// /// @tparam Action Concrete action type registered with `BRIDGE_REGISTER_ACTION`. @@ -597,4 +688,26 @@ class BridgeHandler { std::shared_ptr _subs; }; +/// Out-of-line definition of ActionExecuteRegistry::registerAction. +/// Placed here after BridgeHandler is fully defined so we can safely cast and call its methods. +template +inline void ActionExecuteRegistry::registerAction(std::string_view modelId, std::string_view actionId) { + Key key{std::string{modelId}, std::string{actionId}}; + _executors[key] = [](void* handlerVoid, std::string_view bodyJson) -> ::morph::async::Completion { + auto* handler = static_cast*>(handlerVoid); + auto resultState = std::make_shared<::morph::async::detail::CompletionState>(); + try { + Action action = ::morph::model::ActionTraits::fromJson(bodyJson); + handler->template execute(std::move(action)) + .then([resultState](typename ::morph::model::ActionTraits::Result result) { + resultState->setValue(::morph::model::ActionTraits::resultToJson(result)); + }) + .onError([resultState](const std::exception_ptr& err) { resultState->setException(err); }); + } catch (...) { + resultState->setException(std::current_exception()); + } + return {resultState, handler->guiExecutor()}; + }; +} + } // namespace morph::bridge diff --git a/include/morph/registry.hpp b/include/morph/registry.hpp index 60901a3..9de2905 100644 --- a/include/morph/registry.hpp +++ b/include/morph/registry.hpp @@ -290,6 +290,14 @@ inline bool registerActionOnce(std::string_view modelId, std::string_view action return true; } +/// @brief Static-init helper for `BRIDGE_REGISTER_ACTION`'s generic-execute registration. +/// +/// Forward declaration only; the definition is in `bridge.hpp` (after `ActionExecuteRegistry` +/// is visible) to avoid a `registry.hpp` -> `bridge.hpp` include cycle. `bridge.hpp` already +/// includes `registry.hpp`, not the other way round. +template +bool registerActionExecutorOnce(std::string_view modelId, std::string_view actionId) noexcept; + } // namespace detail } // namespace morph::model @@ -379,6 +387,8 @@ inline bool registerActionOnce(std::string_view modelId, std::string_view action namespace { \ [[maybe_unused]] const bool bridge_action_reg_##M##_##A = \ morph::model::detail::registerActionOnce(morph::model::ModelTraits::typeId(), NAME); \ + [[maybe_unused]] const bool bridge_action_exec_reg_##M##_##A = \ + morph::model::detail::registerActionExecutorOnce(morph::model::ModelTraits::typeId(), NAME); \ } /// @endcond diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b413c94..db858d9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,6 +12,7 @@ add_executable(morph_tests test_registry_extra.cpp test_bridge_local.cpp test_bridge_remote.cpp + test_bridge_execute_json.cpp test_remote_extra.cpp test_dispatch_di.cpp test_handler_binding.cpp diff --git a/tests/test_bridge_execute_json.cpp b/tests/test_bridge_execute_json.cpp new file mode 100644 index 0000000..c12de89 --- /dev/null +++ b/tests/test_bridge_execute_json.cpp @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +// Test-local model — only used in this translation unit +struct AddNumbers { + int a = 0; + int b = 0; +}; + +struct AddResult { + int sum = 0; +}; + +struct MathModel { + AddResult execute(const AddNumbers& action) { return AddResult{.sum = action.a + action.b}; } +}; + +BRIDGE_REGISTER_MODEL(MathModel, "Test_ExecJson_MathModel") +BRIDGE_REGISTER_ACTION(MathModel, AddNumbers, "Test_ExecJson_AddNumbers") + +using SyncExecutor = morph::testing::InlineExecutor; + +TEST_CASE("ActionExecuteRegistry: executeJson deserialises, executes, and re-serialises", "[bridge][execute-json]") { + morph::exec::ThreadPoolExecutor pool{2}; + SyncExecutor cbExec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &cbExec}; + + std::optional resultJson; + std::atomic done{false}; + handler.executeJson("Test_ExecJson_AddNumbers", R"({"a":3,"b":4})") + .then([&](std::string json) { + resultJson = std::move(json); + done.store(true); + }) + .onError([&](const std::exception_ptr&) { done.store(true); }); + + REQUIRE(morph::testing::waitUntil([&] { return done.load(); })); + REQUIRE(resultJson.has_value()); + REQUIRE(*resultJson == R"({"sum":7})"); +} + +TEST_CASE("ActionExecuteRegistry: executeJson reports parse errors via onError", "[bridge][execute-json]") { + morph::exec::ThreadPoolExecutor pool{2}; + SyncExecutor cbExec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &cbExec}; + + bool sawError = false; + std::atomic done{false}; + handler.executeJson("Test_ExecJson_AddNumbers", R"({"a":"not a number"})") + .then([&](std::string) { done.store(true); }) + .onError([&](const std::exception_ptr&) { + sawError = true; + done.store(true); + }); + + REQUIRE(morph::testing::waitUntil([&] { return done.load(); })); + REQUIRE(sawError); +} + +TEST_CASE("ActionExecuteRegistry: unknown action type throws", "[bridge][execute-json]") { + morph::exec::ThreadPoolExecutor pool{2}; + SyncExecutor cbExec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &cbExec}; + + REQUIRE_THROWS_AS(handler.executeJson("NoSuchAction", "{}"), std::runtime_error); +} diff --git a/tests/test_quantity_forms.cpp b/tests/test_quantity_forms.cpp index 55a8670..4cad37f 100644 --- a/tests/test_quantity_forms.cpp +++ b/tests/test_quantity_forms.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +#include #include #include #include diff --git a/tests/test_server_limits.cpp b/tests/test_server_limits.cpp index 41d76cf..fd82e9e 100644 --- a/tests/test_server_limits.cpp +++ b/tests/test_server_limits.cpp @@ -6,6 +6,7 @@ // support (see catch2/benchmark). #include +#include #include #include #include From 5b1ef0bd77a83a542ed3dc51f5f5a4a324076032 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 6 Jul 2026 14:09:37 +0200 Subject: [PATCH 06/13] fix: require bridge.hpp wherever BRIDGE_REGISTER_ACTION is called registerActionExecutorOnce is declared in registry.hpp but only defined in bridge.hpp (to avoid a registry.hpp -> bridge.hpp include cycle). BRIDGE_REGISTER_ACTION_4 calls it unconditionally at static-init time, so any translation unit that invokes BRIDGE_REGISTER_ACTION without also including in that same TU fails to link with an unresolved external symbol. A prior commit patched 3 files that broke the default ctest build (examples/forms/main.cpp, tests/test_quantity_forms.cpp, tests/test_server_limits.cpp) but left every other call site unaudited. A repo-wide grep for BRIDGE_REGISTER_ACTION turned up 19 more call sites needing the include: the 10 bank model headers under examples/bank/include/bank/models/ (only built with -DMORPH_BUILD_BANK_EXAMPLE=ON, off by default, which is why the default build never caught this), the 7 WASM-shadow bank model headers under examples/bank/gui_wasm/include/bank/models/, tests/qt/qt_test_models.hpp, and examples/forms/lab_model.hpp. Fix: add #include to every header that calls BRIDGE_REGISTER_ACTION, and document the requirement as a hard rule on registerActionExecutorOnce's declaration, on BRIDGE_REGISTER_ACTION's own doc comment, and symmetrically on ActionExecuteRegistry's doc comment in bridge.hpp. Verified: default ctest suite still passes 352/352 with no regressions. The bank example itself could not be built end-to-end in this environment (Lightweight's yaml-cpp dependency isn't provisioned, and even once it is, Lightweight fails to *compile* against this MSVC toolset independent of this fix) -- instead verified the exact link mechanism in isolation with a throwaway two-TU repro, confirming the predicted LNK2019 on registerActionExecutorOnce without the fix and a clean link with it. Co-Authored-By: Claude Sonnet 5 --- .../gui_wasm/include/bank/models/account_model.hpp | 1 + .../gui_wasm/include/bank/models/auth_model.hpp | 1 + .../gui_wasm/include/bank/models/card_model.hpp | 1 + .../gui_wasm/include/bank/models/loan_model.hpp | 1 + .../gui_wasm/include/bank/models/payee_model.hpp | 1 + .../gui_wasm/include/bank/models/payment_model.hpp | 1 + .../include/bank/models/transaction_model.hpp | 1 + examples/bank/include/bank/models/account_model.hpp | 1 + examples/bank/include/bank/models/auth_model.hpp | 1 + examples/bank/include/bank/models/budget_model.hpp | 1 + examples/bank/include/bank/models/card_model.hpp | 1 + examples/bank/include/bank/models/loan_model.hpp | 1 + .../bank/include/bank/models/notification_model.hpp | 1 + examples/bank/include/bank/models/payee_model.hpp | 1 + examples/bank/include/bank/models/payment_model.hpp | 1 + .../bank/include/bank/models/statement_model.hpp | 1 + .../bank/include/bank/models/transaction_model.hpp | 1 + examples/forms/lab_model.hpp | 1 + include/morph/bridge.hpp | 8 ++++++++ include/morph/registry.hpp | 13 +++++++++++++ tests/qt/qt_test_models.hpp | 1 + 21 files changed, 40 insertions(+) diff --git a/examples/bank/gui_wasm/include/bank/models/account_model.hpp b/examples/bank/gui_wasm/include/bank/models/account_model.hpp index 594aad0..9874753 100644 --- a/examples/bank/gui_wasm/include/bank/models/account_model.hpp +++ b/examples/bank/gui_wasm/include/bank/models/account_model.hpp @@ -4,6 +4,7 @@ // WASM shadow of include/bank/models/account_model.hpp (in-memory backend). #include +#include #include "bank/dto/account_dto.hpp" diff --git a/examples/bank/gui_wasm/include/bank/models/auth_model.hpp b/examples/bank/gui_wasm/include/bank/models/auth_model.hpp index 3f444e7..3f565c8 100644 --- a/examples/bank/gui_wasm/include/bank/models/auth_model.hpp +++ b/examples/bank/gui_wasm/include/bank/models/auth_model.hpp @@ -7,6 +7,7 @@ // is placed first on the WASM include path so it wins over the native one. #include +#include #include "bank/dto/auth_dto.hpp" #include "bank/dto/common.hpp" diff --git a/examples/bank/gui_wasm/include/bank/models/card_model.hpp b/examples/bank/gui_wasm/include/bank/models/card_model.hpp index e3b9cf4..8068921 100644 --- a/examples/bank/gui_wasm/include/bank/models/card_model.hpp +++ b/examples/bank/gui_wasm/include/bank/models/card_model.hpp @@ -4,6 +4,7 @@ // WASM shadow of include/bank/models/card_model.hpp (in-memory backend). #include +#include #include "bank/dto/card_dto.hpp" #include "bank/dto/common.hpp" diff --git a/examples/bank/gui_wasm/include/bank/models/loan_model.hpp b/examples/bank/gui_wasm/include/bank/models/loan_model.hpp index c51e531..66c0c82 100644 --- a/examples/bank/gui_wasm/include/bank/models/loan_model.hpp +++ b/examples/bank/gui_wasm/include/bank/models/loan_model.hpp @@ -4,6 +4,7 @@ // WASM shadow of include/bank/models/loan_model.hpp (in-memory backend). #include +#include #include "bank/dto/loan_dto.hpp" diff --git a/examples/bank/gui_wasm/include/bank/models/payee_model.hpp b/examples/bank/gui_wasm/include/bank/models/payee_model.hpp index 08337f0..ff690d3 100644 --- a/examples/bank/gui_wasm/include/bank/models/payee_model.hpp +++ b/examples/bank/gui_wasm/include/bank/models/payee_model.hpp @@ -4,6 +4,7 @@ // WASM shadow of include/bank/models/payee_model.hpp (in-memory backend). #include +#include #include "bank/dto/common.hpp" #include "bank/dto/payee_dto.hpp" diff --git a/examples/bank/gui_wasm/include/bank/models/payment_model.hpp b/examples/bank/gui_wasm/include/bank/models/payment_model.hpp index b574680..717e823 100644 --- a/examples/bank/gui_wasm/include/bank/models/payment_model.hpp +++ b/examples/bank/gui_wasm/include/bank/models/payment_model.hpp @@ -4,6 +4,7 @@ // WASM shadow of include/bank/models/payment_model.hpp (in-memory backend). #include +#include #include "bank/dto/common.hpp" #include "bank/dto/payment_dto.hpp" diff --git a/examples/bank/gui_wasm/include/bank/models/transaction_model.hpp b/examples/bank/gui_wasm/include/bank/models/transaction_model.hpp index e6fd815..4f0a9c9 100644 --- a/examples/bank/gui_wasm/include/bank/models/transaction_model.hpp +++ b/examples/bank/gui_wasm/include/bank/models/transaction_model.hpp @@ -4,6 +4,7 @@ // WASM shadow of include/bank/models/transaction_model.hpp (in-memory backend). #include +#include #include "bank/dto/transaction_dto.hpp" diff --git a/examples/bank/include/bank/models/account_model.hpp b/examples/bank/include/bank/models/account_model.hpp index 15d252c..57b3a70 100644 --- a/examples/bank/include/bank/models/account_model.hpp +++ b/examples/bank/include/bank/models/account_model.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include "bank/db/db_model.hpp" #include "bank/dto/account_dto.hpp" diff --git a/examples/bank/include/bank/models/auth_model.hpp b/examples/bank/include/bank/models/auth_model.hpp index 6161597..43c9571 100644 --- a/examples/bank/include/bank/models/auth_model.hpp +++ b/examples/bank/include/bank/models/auth_model.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include "bank/db/db_model.hpp" #include "bank/dto/auth_dto.hpp" diff --git a/examples/bank/include/bank/models/budget_model.hpp b/examples/bank/include/bank/models/budget_model.hpp index e6b82dd..f02998c 100644 --- a/examples/bank/include/bank/models/budget_model.hpp +++ b/examples/bank/include/bank/models/budget_model.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include "bank/db/db_model.hpp" #include "bank/dto/budget_dto.hpp" diff --git a/examples/bank/include/bank/models/card_model.hpp b/examples/bank/include/bank/models/card_model.hpp index c1c3415..3adfa33 100644 --- a/examples/bank/include/bank/models/card_model.hpp +++ b/examples/bank/include/bank/models/card_model.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include "bank/db/db_model.hpp" #include "bank/dto/card_dto.hpp" diff --git a/examples/bank/include/bank/models/loan_model.hpp b/examples/bank/include/bank/models/loan_model.hpp index a2b40ed..7dfec33 100644 --- a/examples/bank/include/bank/models/loan_model.hpp +++ b/examples/bank/include/bank/models/loan_model.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include "bank/db/db_model.hpp" #include "bank/dto/loan_dto.hpp" diff --git a/examples/bank/include/bank/models/notification_model.hpp b/examples/bank/include/bank/models/notification_model.hpp index 6903296..6f71f46 100644 --- a/examples/bank/include/bank/models/notification_model.hpp +++ b/examples/bank/include/bank/models/notification_model.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include "bank/db/db_model.hpp" #include "bank/dto/common.hpp" diff --git a/examples/bank/include/bank/models/payee_model.hpp b/examples/bank/include/bank/models/payee_model.hpp index 79eb02d..e8feedd 100644 --- a/examples/bank/include/bank/models/payee_model.hpp +++ b/examples/bank/include/bank/models/payee_model.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include "bank/db/db_model.hpp" #include "bank/dto/common.hpp" diff --git a/examples/bank/include/bank/models/payment_model.hpp b/examples/bank/include/bank/models/payment_model.hpp index f978baf..2fd1747 100644 --- a/examples/bank/include/bank/models/payment_model.hpp +++ b/examples/bank/include/bank/models/payment_model.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include "bank/db/db_model.hpp" #include "bank/dto/common.hpp" diff --git a/examples/bank/include/bank/models/statement_model.hpp b/examples/bank/include/bank/models/statement_model.hpp index db75717..7eaf431 100644 --- a/examples/bank/include/bank/models/statement_model.hpp +++ b/examples/bank/include/bank/models/statement_model.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include "bank/db/db_model.hpp" #include "bank/dto/statement_dto.hpp" diff --git a/examples/bank/include/bank/models/transaction_model.hpp b/examples/bank/include/bank/models/transaction_model.hpp index a3383b3..f952de6 100644 --- a/examples/bank/include/bank/models/transaction_model.hpp +++ b/examples/bank/include/bank/models/transaction_model.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include "bank/db/db_model.hpp" #include "bank/dto/transaction_dto.hpp" diff --git a/examples/forms/lab_model.hpp b/examples/forms/lab_model.hpp index ec03082..8f2f8d2 100644 --- a/examples/forms/lab_model.hpp +++ b/examples/forms/lab_model.hpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include diff --git a/include/morph/bridge.hpp b/include/morph/bridge.hpp index 8aede77..623f434 100644 --- a/include/morph/bridge.hpp +++ b/include/morph/bridge.hpp @@ -33,6 +33,14 @@ namespace morph::bridge { /// call sites), unlike `morph::model::detail::ActionDispatcher`, which /// calls `Model::execute` directly against an already-owned model holder /// and is only ever used server-side. +/// +/// HARD REQUIREMENT (other direction of the same constraint documented on +/// `morph::model::detail::registerActionExecutorOnce` in `registry.hpp`): registration into +/// this registry happens via `registerActionExecutorOnce`, which +/// `BRIDGE_REGISTER_ACTION` calls unconditionally but which is only defined here, in +/// `bridge.hpp`. Every translation unit that calls `BRIDGE_REGISTER_ACTION` must therefore +/// include this header (directly or transitively), or that translation unit's static +/// initializer will fail to link. class ActionExecuteRegistry { public: /// @brief Deserialises `bodyJson`, dispatches through the handler's diff --git a/include/morph/registry.hpp b/include/morph/registry.hpp index 9de2905..d5e3819 100644 --- a/include/morph/registry.hpp +++ b/include/morph/registry.hpp @@ -295,6 +295,12 @@ inline bool registerActionOnce(std::string_view modelId, std::string_view action /// Forward declaration only; the definition is in `bridge.hpp` (after `ActionExecuteRegistry` /// is visible) to avoid a `registry.hpp` -> `bridge.hpp` include cycle. `bridge.hpp` already /// includes `registry.hpp`, not the other way round. +/// +/// HARD REQUIREMENT: `BRIDGE_REGISTER_ACTION_4` calls this function unconditionally, but only +/// declares it here — it does NOT define it. Any translation unit that invokes +/// `BRIDGE_REGISTER_ACTION` MUST include `` (directly or transitively) +/// somewhere in the same translation unit, or the build will fail to link with an unresolved +/// external symbol for this function. template bool registerActionExecutorOnce(std::string_view modelId, std::string_view actionId) noexcept; @@ -336,6 +342,13 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio /// BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount", morph::model::Loggable::No) // opt out /// @endcode /// +/// HARD REQUIREMENT: this macro's expansion unconditionally calls +/// `morph::model::detail::registerActionExecutorOnce`, which is only *declared* in +/// `registry.hpp` and *defined* in ``. Every translation unit that invokes +/// `BRIDGE_REGISTER_ACTION` MUST include `` (directly or transitively) in +/// that same translation unit, or the build will fail to link with an unresolved external +/// symbol for `registerActionExecutorOnce`. +/// /// @param M Concrete model type that handles the action. /// @param A Concrete action type. /// @param NAME String literal used as the action type-id. diff --git a/tests/qt/qt_test_models.hpp b/tests/qt/qt_test_models.hpp index 894c651..dfdbdd9 100644 --- a/tests/qt/qt_test_models.hpp +++ b/tests/qt/qt_test_models.hpp @@ -9,6 +9,7 @@ #pragma once #include +#include #include struct ProcTestEchoAction { From 8f514fced393b0e139f7b823182149bc67b8a99c Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 6 Jul 2026 14:19:22 +0200 Subject: [PATCH 07/13] refactor: FormsController dispatches through Bridge/BridgeHandler Replaces hand-built wire::Envelope + RemoteServer plumbing with the framework's real client API, matching how examples/bank's GUI talks to its models. submit() is renamed submitIfValid() and now goes through BridgeHandler::executeJson (added in the previous commit) instead of a manually assembled envelope. --- examples/forms/gui_qml/FormsController.cpp | 108 ++++++++------------- examples/forms/gui_qml/FormsController.hpp | 61 ++++++------ 2 files changed, 75 insertions(+), 94 deletions(-) diff --git a/examples/forms/gui_qml/FormsController.cpp b/examples/forms/gui_qml/FormsController.cpp index 95da68a..97a840b 100644 --- a/examples/forms/gui_qml/FormsController.cpp +++ b/examples/forms/gui_qml/FormsController.cpp @@ -2,87 +2,63 @@ #include "FormsController.hpp" -#include -#include - #include +#include #include -#include -#include +#include +#include -#include "lab_model.hpp" #include "lab_schemas.hpp" -FormsController::FormsController(QObject* parent) : QObject{parent} { - _server = std::make_shared(_pool); +namespace { - // Register one LabModel instance over the wire protocol; the ok-reply - // carries the server-assigned model id every later execute targets. - auto registerMsg = morph::wire::encode(morph::wire::makeRegister("LabModel")); - _server->handle(registerMsg, [this](const std::string& replyJson) { - try { - auto reply = morph::wire::decode(replyJson); - if (reply.kind == "ok") { - _modelId.store(reply.modelId); - } - } catch (const std::exception&) { - // Leave _modelId at 0; submits will report "not registered". +QString errorText(const std::exception_ptr& err) { + try { + if (err) { + std::rethrow_exception(err); } - }); + } catch (const std::exception& exc) { + return QString::fromUtf8(exc.what()); + } catch (...) { + return QStringLiteral("unknown error"); + } + return {}; } +} // namespace + +FormsController::FormsController(QObject* parent) + : QObject{parent}, + _bridge{std::make_unique(_pool)}, + _handler{_bridge, &_gui} {} + QString FormsController::schemasJson() const { return QString::fromStdString(lab::schemasJson()); } -void FormsController::submit(const QString& actionType, const QString& bodyJson) { - sendExecute(actionType, bodyJson, /*asOptions=*/false); +void FormsController::submitIfValid(const QString& actionType, const QString& bodyJson) { + auto const actionTypeStd = actionType.toStdString(); + _handler.executeJson(actionTypeStd, bodyJson.toStdString()) + .then([this, actionType](std::string resultJson) { + emit replyReceived(actionType, true, QString::fromStdString(resultJson)); + }) + .onError([this, actionType](const std::exception_ptr& err) { + emit replyReceived(actionType, false, errorText(err)); + }); } void FormsController::fetchOptions(const QString& optionsAction) { - sendExecute(optionsAction, QStringLiteral("{}"), /*asOptions=*/true); -} - -void FormsController::sendExecute(const QString& actionType, const QString& bodyJson, bool asOptions) { - if (_modelId.load() == 0) { - auto const message = QStringLiteral("model not registered yet"); - if (asOptions) { - emit optionsReceived(actionType, false, message); - } else { - emit replyReceived(actionType, false, message); - } - return; - } - - morph::wire::Envelope envelope; - envelope.kind = "execute"; - envelope.callId = _nextCallId.fetch_add(1); - envelope.modelId = _modelId.load(); - envelope.modelType = "LabModel"; - envelope.actionType = actionType.toStdString(); - envelope.body = bodyJson.toStdString(); - - _server->handle(morph::wire::encode(envelope), [this, actionType, asOptions](const std::string& replyJson) { - bool ok = false; - QString payload; - try { - auto reply = morph::wire::decode(replyJson); - ok = reply.kind == "ok"; - payload = QString::fromStdString(ok ? reply.body : reply.message); - } catch (const std::exception& error) { - payload = QString::fromUtf8(error.what()); - } - // The reply lands on a worker-pool thread; hop to the GUI thread. - QMetaObject::invokeMethod( - this, - [this, actionType, ok, payload, asOptions] { - if (asOptions) { - emit optionsReceived(actionType, ok, payload); - } else { - emit replyReceived(actionType, ok, payload); - } - }, - Qt::QueuedConnection); - }); + _handler.execute(lab::ListSamples{}) + .then([this, optionsAction](lab::SampleList list) { + std::string json; + if (glz::write_json(list, json)) { + emit optionsReceived(optionsAction, false, QStringLiteral("failed to encode options")); + return; + } + emit optionsReceived(optionsAction, true, QString::fromStdString(json)); + }) + .onError([this, optionsAction](const std::exception_ptr& err) { + emit optionsReceived(optionsAction, false, errorText(err)); + }); } diff --git a/examples/forms/gui_qml/FormsController.hpp b/examples/forms/gui_qml/FormsController.hpp index 225ed66..08cb424 100644 --- a/examples/forms/gui_qml/FormsController.hpp +++ b/examples/forms/gui_qml/FormsController.hpp @@ -4,23 +4,30 @@ /// @file /// QML-facing controller for the schema-driven forms demo. /// -/// The QML layer is deliberately a *JSON-speaking client*: it renders forms -/// from the schemas exposed here and submits action bodies as JSON strings. -/// This controller wraps them in `morph::wire::Envelope`s and feeds them to -/// an in-process `RemoteServer` — the exact protocol a networked deployment -/// would run over a WebSocket, minus the socket. Swapping to -/// `QtWebSocketBackend`/`QtWebSocketServer` changes this file, not the QML. +/// The QML layer renders forms from the schemas exposed here and submits +/// fully-assembled action bodies as JSON strings via `submitIfValid`, +/// called on every edit once the body validates client-side (no submit +/// button). This controller dispatches through a real `morph::bridge::Bridge` +/// + `BridgeHandler` — the same client API `examples/bank`'s GUI +/// uses — via the generic `BridgeHandler::executeJson` path, so it never +/// touches `morph::wire::Envelope` or `morph::backend::RemoteServer` +/// directly. #include #include #include -#include -#include -#include - +// Guarded like examples/bank/gui/controllers/AccountController.hpp: MOC +// only needs the Q_OBJECT/QML_ELEMENT macros above and the Q_INVOKABLE/ +// Q_PROPERTY declarations below; it must not be pointed at morph's +// template-heavy headers (bridge.hpp, glaze) or the Qt executor. +#ifndef Q_MOC_RUN +#include #include -#include +#include + +#include "lab_model.hpp" +#endif class FormsController : public QObject { Q_OBJECT @@ -34,9 +41,12 @@ class FormsController : public QObject { [[nodiscard]] QString schemasJson() const; - /// @brief Sends one `execute` envelope with @p bodyJson as the action payload. - /// The reply arrives via `replyReceived` on the GUI thread. - Q_INVOKABLE void submit(const QString& actionType, const QString& bodyJson); + /// @brief Dispatches @p bodyJson as the body of @p actionType if the + /// body is complete. Called by QML on every field edit once the + /// assembled body passes client-side validation — there is no + /// separate submit step. The reply arrives via `replyReceived` + /// on the GUI thread. + Q_INVOKABLE void submitIfValid(const QString& actionType, const QString& bodyJson); /// @brief Executes @p optionsAction with an empty body to fetch combo-box /// options (a `Choice` field's declared provider). The reply @@ -44,8 +54,8 @@ class FormsController : public QObject { Q_INVOKABLE void fetchOptions(const QString& optionsAction); signals: - /// @brief Emitted once per `submit`. @p payload is the result JSON when - /// @p ok, otherwise the server's error message. + /// @brief Emitted once per `submitIfValid` call. @p payload is the + /// result JSON when @p ok, otherwise the error message. void replyReceived(const QString& actionType, bool ok, const QString& payload); /// @brief Emitted once per `fetchOptions`. @p payload is the options @@ -53,17 +63,12 @@ class FormsController : public QObject { void optionsReceived(const QString& optionsAction, bool ok, const QString& payload); private: - /// @brief Shared envelope send: emits @p asOptions ? optionsReceived - /// : replyReceived on the GUI thread when the reply lands. - void sendExecute(const QString& actionType, const QString& bodyJson, bool asOptions); - - std::shared_ptr _server; - std::atomic _modelId{0}; - std::atomic _nextCallId{1}; - - /// @brief Declared last, so it is destroyed first: the pool joins (running - /// any still-queued reply callbacks, which touch the members above) - /// while those members are all still alive. GUI-thread hops queued - /// on a destroyed controller are dropped by Qt's context tracking. + // Declaration order matters for destruction: `_handler` and `_bridge` + // must be torn down before `_pool`/`_gui`, and `_pool` must outlive the + // `LocalBackend` owned inside `_bridge` (constructed from it). Declared + // in construction order so default destruction (reverse order) is safe. morph::exec::ThreadPoolExecutor _pool{2}; + morph::qt::QtExecutor _gui; + morph::bridge::Bridge _bridge; + morph::bridge::BridgeHandler _handler; }; From 4687b498208b514336ec6acbb064c50e61577b03 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 6 Jul 2026 14:25:45 +0200 Subject: [PATCH 08/13] feat: fire forms automatically on valid edit, remove submit button DynamicForm.qml now calls controller.submitIfValid() directly from revalidate() whenever the assembled body passes client-side checks, instead of waiting for a button click. Matches the live-recompute UX the framework's fielded-action API models, without requiring a compile-time set<&Action::field> hook per field. --- examples/forms/gui_qml/qml/DynamicForm.qml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/forms/gui_qml/qml/DynamicForm.qml b/examples/forms/gui_qml/qml/DynamicForm.qml index 32336bc..bc92e59 100644 --- a/examples/forms/gui_qml/qml/DynamicForm.qml +++ b/examples/forms/gui_qml/qml/DynamicForm.qml @@ -224,6 +224,8 @@ Frame { } ready = ok previewLine = ok ? "{" + parts.join(",") + "}" : "" + if (ready && form.controller) + form.controller.submitIfValid(form.actionType, form.previewLine) } function setFieldValue(name, text) { @@ -381,11 +383,11 @@ Frame { } } - Button { + Label { Layout.topMargin: 8 - text: "execute" - enabled: form.ready - onClicked: form.controller.submit(form.actionType, form.previewLine) + text: form.ready ? "✓ executes automatically as you type" : "fill the required (*) fields" + opacity: 0.6 + font.italic: true } Label { From fe9bafe6d3978875603e74f37c46cedca05d97d6 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 6 Jul 2026 14:37:31 +0200 Subject: [PATCH 09/13] test: cover DynamicForm's auto-fire-on-valid behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Qt Quick Test driving DynamicForm through simulated field edits against a mock controller, asserting submitIfValid fires exactly once the form becomes valid and not before — without any button click. --- examples/forms/gui_qml/qml/DynamicForm.qml | 1 + .../gui_qml/tests/tst_DynamicFormReactive.qml | 81 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 examples/forms/gui_qml/tests/tst_DynamicFormReactive.qml diff --git a/examples/forms/gui_qml/qml/DynamicForm.qml b/examples/forms/gui_qml/qml/DynamicForm.qml index bc92e59..adf85c8 100644 --- a/examples/forms/gui_qml/qml/DynamicForm.qml +++ b/examples/forms/gui_qml/qml/DynamicForm.qml @@ -342,6 +342,7 @@ Frame { TextField { id: entry + objectName: "field_" + fieldColumn.modelData.name visible: !fieldColumn.modelData.isChoice && !fieldColumn.modelData.isDateTime Layout.fillWidth: true placeholderText: fieldColumn.modelData.isQuantity diff --git a/examples/forms/gui_qml/tests/tst_DynamicFormReactive.qml b/examples/forms/gui_qml/tests/tst_DynamicFormReactive.qml new file mode 100644 index 0000000..221c3ca --- /dev/null +++ b/examples/forms/gui_qml/tests/tst_DynamicFormReactive.qml @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Covers DynamicForm's auto-fire-on-valid behavior: with no submit button, +// the form must call controller.submitIfValid exactly once, the moment the +// assembled body becomes valid, and not before. + +import QtQuick +import QtTest +import MorphForms + +TestCase { + id: testCase + name: "DynamicFormReactive" + + QtObject { + id: mockController + signal replyReceived(string actionType, bool ok, string payload) + signal optionsReceived(string optionsAction, bool ok, string payload) + + property int submitCount: 0 + property string lastActionType: "" + property string lastBodyJson: "" + + function submitIfValid(actionType, bodyJson) { + submitCount += 1 + lastActionType = actionType + lastBodyJson = bodyJson + replyReceived(actionType, true, JSON.stringify({sum: 42})) + } + + function fetchOptions(optionsAction) { + optionsReceived(optionsAction, true, "[]") + } + } + + property var testSchema: ({ + properties: { + a: { type: "integer", "x-order": 0 }, + b: { type: "integer", "x-order": 1 } + }, + required: ["a", "b"] + }) + + Component { + id: formComponent + DynamicForm { + actionType: "TestAction" + schema: testCase.testSchema + controller: mockController + } + } + + function test_fires_automatically_without_button() { + mockController.submitCount = 0 + var form = createTemporaryObject(formComponent, testCase) + verify(form !== null) + + var fieldA = findChild(form, "field_a") + var fieldB = findChild(form, "field_b") + verify(fieldA !== null) + verify(fieldB !== null) + + fieldA.text = "3" + fieldB.text = "4" + + compare(mockController.submitCount, 1) + compare(mockController.lastActionType, "TestAction") + compare(form.resultText !== "", true) + } + + function test_does_not_fire_when_incomplete() { + mockController.submitCount = 0 + var form = createTemporaryObject(formComponent, testCase) + verify(form !== null) + + var fieldA = findChild(form, "field_a") + verify(fieldA !== null) + fieldA.text = "3" + compare(mockController.submitCount, 0) + } +} From dc32c24bfbdf0184e47cc9b5fb152d699f289d7b Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 6 Jul 2026 14:43:48 +0200 Subject: [PATCH 10/13] fix: deploy Qt runtime + offscreen plugin for morph_forms_qml_tests The existing windeployqt POST_BUILD step only covered morph_forms_qml (the GUI app), not morph_forms_qml_tests. Running ctest -R forms_qml_logic outside a shell with Qt's bin/plugins already on PATH hung ~200s then crashed with STATUS_DLL_NOT_FOUND (missing Qt6QuickTestd.dll and, even with a full PATH, the offscreen QPA plugin the test forces via QT_QPA_PLATFORM=offscreen -- windeployqt's static analysis doesn't see that env-var-only platform selection, so it skipped deploying qoffscreend.dll by default). Fix: run windeployqt for morph_forms_qml_tests too, with --include-plugins qoffscreen forcing the plugin deployment windeployqt would otherwise omit. Verified ctest -R forms_qml_logic now passes in under a second with no Qt-related PATH/QT_PLUGIN_PATH setup at all. Co-Authored-By: Claude Sonnet 5 --- examples/forms/gui_qml/CMakeLists.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/examples/forms/gui_qml/CMakeLists.txt b/examples/forms/gui_qml/CMakeLists.txt index 8f58b82..ccc731c 100644 --- a/examples/forms/gui_qml/CMakeLists.txt +++ b/examples/forms/gui_qml/CMakeLists.txt @@ -59,6 +59,21 @@ if(MORPH_BUILD_TESTS) qt_add_executable(morph_forms_qml_tests tests/tst_main.cpp) target_link_libraries(morph_forms_qml_tests PRIVATE morph_forms_moduleplugin Qt6::QuickTest) + # Same rationale as morph_forms_qml above: without this, running + # `ctest -R forms_qml_logic` (or the .exe directly) outside a shell that + # already has Qt's bin/plugins on PATH hangs briefly then fails with + # STATUS_DLL_NOT_FOUND, since Qt6QuickTestd.dll and the offscreen QPA + # plugin are not otherwise deployed next to the test binary. + if(WIN32 AND MORPH_WINDEPLOYQT_EXECUTABLE) + add_custom_command(TARGET morph_forms_qml_tests POST_BUILD + COMMAND "${MORPH_WINDEPLOYQT_EXECUTABLE}" + --qmldir "${CMAKE_CURRENT_SOURCE_DIR}/qml" + --include-plugins qoffscreen + $ + COMMENT "Deploying Qt runtime DLLs next to morph_forms_qml_tests.exe" + ) + endif() + add_test(NAME forms_qml_logic COMMAND morph_forms_qml_tests -input ${CMAKE_CURRENT_SOURCE_DIR}/tests) set_tests_properties(forms_qml_logic PROPERTIES From 76e0bd30413e007c01fba11dec07b55bae1b4b11 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 6 Jul 2026 14:44:33 +0200 Subject: [PATCH 11/13] docs: describe the Bridge/BridgeHandler-based reactive forms flow Updates the forms demo README to match FormsController's new transport (Bridge/BridgeHandler via executeJson) and UX (auto-fire on valid edit, no submit button), replacing the outdated RemoteServer/wire::Envelope description. --- examples/forms/README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/forms/README.md b/examples/forms/README.md index e3f1ca1..1079c07 100644 --- a/examples/forms/README.md +++ b/examples/forms/README.md @@ -87,10 +87,15 @@ validators, so models guard their own preconditions. ``` `qml/DynamicForm.qml` renders any action schema at runtime (same rules as the -HTML renderer). Submits travel as real `morph::wire::Envelope`s through an -in-process `RemoteServer` — the QML GUI is a JSON-speaking wire client, so -pointing it at a networked server later means swapping the transport inside -`FormsController`, not touching the QML. +HTML renderer). The Qt Quick renderer dispatches actions through `morph::bridge::Bridge` + +`BridgeHandler` — the same client API `examples/bank`'s GUI uses — via a generic, +JSON-in/JSON-out `executeJson` path (see `BridgeHandler::executeJson` in `morph/bridge.hpp`). +Forms have no submit button: each form fires automatically the moment its assembled body +passes client-side validation, and re-fires on every subsequent edit. Because each keystroke +can trigger a fresh dispatch with no coalescing, rapid edits may produce out-of-order replies +for the same action (last-arrival wins on the displayed result) — acceptable for this demo's +read-mostly compute actions, but worth knowing before reusing this pattern for actions with +side effects. ## Tests From 1380848fdb112dff275661ef1c600d63e6c5a226 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 7 Jul 2026 18:15:17 +0200 Subject: [PATCH 12/13] docs: fix Doxygen errors, merge superpowers spec+plan into one file The Docs CI job failed because ActionExecuteRegistry's registerAction, execute, and instance were missing @param/@return documentation, which FAIL_ON_WARNINGS turns into a build failure. Also merge the reactive forms design spec and implementation plan into a single docs/superpowers/2026-07-06-reactive-forms-bridge.md. Co-Authored-By: Claude Fable 5 --- .../2026-07-06-reactive-forms-bridge.md | 267 +++++++++++++++++- ...2026-07-06-reactive-forms-bridge-design.md | 240 ---------------- include/morph/bridge.hpp | 10 + 3 files changed, 268 insertions(+), 249 deletions(-) rename docs/superpowers/{plans => }/2026-07-06-reactive-forms-bridge.md (77%) delete mode 100644 docs/superpowers/specs/2026-07-06-reactive-forms-bridge-design.md diff --git a/docs/superpowers/plans/2026-07-06-reactive-forms-bridge.md b/docs/superpowers/2026-07-06-reactive-forms-bridge.md similarity index 77% rename from docs/superpowers/plans/2026-07-06-reactive-forms-bridge.md rename to docs/superpowers/2026-07-06-reactive-forms-bridge.md index e88aa1f..a07091a 100644 --- a/docs/superpowers/plans/2026-07-06-reactive-forms-bridge.md +++ b/docs/superpowers/2026-07-06-reactive-forms-bridge.md @@ -1,4 +1,253 @@ -# Reactive Forms Over BridgeHandler Implementation Plan +# Reactive Forms over Bridge/BridgeHandler + +Date: 2026-07-06 + +Combined superpowers documentation: the design spec followed by the implementation plan (previously `specs/2026-07-06-reactive-forms-bridge-design.md` and `plans/2026-07-06-reactive-forms-bridge.md`). + +--- + +## Reactive forms demo over BridgeHandler + +### Problem + +`examples/forms/gui_qml/FormsController` does not use the framework's real +client API. It builds `morph::wire::Envelope`s by hand and feeds them +directly to an in-process `morph::backend::RemoteServer`, bypassing +`Bridge`/`BridgeHandler` entirely. That is internal server-side wire +protocol plumbing — no real client is expected to touch `wire::Envelope` or +`RemoteServer` directly. Every other GUI in this codebase (`examples/bank`) +talks to models exclusively through `BridgeHandler`. + +Separately, the forms demo is not reactive: each form has an explicit +"execute" button; nothing recomputes until the user clicks it. + +**Investigated and rejected:** the framework's field-by-field, auto-fire +API — `BridgeHandler::set<&Action::field>(value)` / `subscribe(cb)` +(used today only in `tests/test_subscription.cpp`) — looked like the +natural fit, since it fires automatically once a draft becomes valid. +It does not work here: `set` requires a real compile-time +pointer-to-member (`double Action::*`) as a template argument, but +`DynamicForm.qml` only knows field names as runtime strings from the JSON +schema. Generating that pointer generically from glaze reflection was +checked directly against glaze's `reflect` implementation +(`glaze/core/reflect.hpp`): for a plain aggregate with no `glz::meta` +specialisation (every action in this codebase, e.g. `RecordMeasurement`), +glaze's `reflectable` reflection path exposes only field **names** +(`keys`) and a `to_tie()`-based tuple of runtime references — no +compile-time member-pointer artifact exists to extract. Producing one +per field would need either C++26 static reflection (not in use here) or +a hand-written `glz::meta` per action declaring each member +pointer explicitly, which reintroduces exactly the per-action boilerplate +this design exists to avoid. `set<>`/`subscribe<>` is therefore left as +what it is today — a compile-time API for hand-written C++ call sites — +and out of scope for the schema-driven QML forms. + +The chosen alternative keeps the *outcome* (fires automatically as the +user types, no button) without needing a field-level compile-time hook: +the controller assembles the full action body as JSON (the QML side +already does this for the current submit button) and calls a new +generic, JSON-in/JSON-out execute path through `Bridge`/`BridgeHandler` +whenever the assembled body is schema-valid. This still requires +resolving a runtime action-type string to a concrete C++ `Action` type to +call `BridgeHandler::execute()` — the same shape of problem, one +level up. It is solved the same way `ActionDispatcher` already solves it +for `RemoteServer` (`registry.hpp`): a static-init-populated map from +action-type string to a type-erased closure, built automatically inside +`BRIDGE_REGISTER_ACTION` (no new macro, no new per-action declaration). + +### Goals + +- `FormsController` talks to `LabModel` exclusively through + `Bridge`/`BridgeHandler` — no `wire::Envelope`, no + `RemoteServer`, no `ActionDispatcher`/`ModelRegistryFactory` singletons + touched from the GUI layer. +- Forms fire automatically as soon as their required fields are valid + (live recompute) — no submit button in the reactive path. +- Zero new boilerplate per action: `lab_model.hpp` (and any future action + file) needs no changes beyond what `BRIDGE_REGISTER_ACTION` already + requires today. +- `examples/forms/main.cpp` (schema dump / `--emit-html` / REPL) is + untouched — it has no GUI and does not use `FormsController`. + +### Non-goals + +- No change to the JSON Schema shape (`morph::forms::schemaJson`) or to + `x-order`/`x-decimalPlaces`/`x-optionsAction` metadata. +- No change to `BridgeHandler`'s existing `execute()`/one-shot API — it + stays as-is for `fetchOptions` (`ListSamples` is a plain query, not a + fielded draft). +- No attempt to make the *options* combo-box fetch reactive; it remains + fetched once per form (as today), triggered from `Component.onCompleted`. +- The static `--emit-html` page (pure JS, no C++ backend at all) is out of + scope; it already works standalone and has its own test + (`test_html_math.mjs`). + +### Design + +#### 1. `ActionExecuteRegistry` (new, in `include/morph/bridge.hpp`) + +A process-wide singleton with the same shape as the existing +`ActionDispatcher` (`registry.hpp`): a static-init-populated map, keyed by +`(modelId, actionId)`, of type-erased closures — except this closure calls +through `Bridge::executeVia` (routing, sessions, backend +switches, completions) instead of `ActionDispatcher::Runner`, which calls +`Model::execute` directly against an already-owned `IModelHolder` and is +only ever invoked server-side (inside `RemoteServer`/`ActionDispatcher` +dispatch, never from a client). + +```cpp +class ActionExecuteRegistry { +public: + // Deserialises `bodyJson` into the concrete Action, dispatches it + // through the handler's Bridge, and resolves with the JSON-encoded + // result (or the exception, unchanged) on the handler's gui executor. + using Executor = std::function<::morph::async::Completion(void* /* BridgeHandler* */, + std::string_view bodyJson)>; + + template + void registerAction(std::string_view modelId, std::string_view actionId); + + ::morph::async::Completion execute(std::string_view modelId, std::string_view actionId, + void* handler, std::string_view bodyJson) const; // throws if unknown + + static ActionExecuteRegistry& instance(); +}; +``` + +The registered closure: + +```cpp +[](void* handlerVoid, std::string_view bodyJson) -> ::morph::async::Completion { + auto* handler = static_cast*>(handlerVoid); + Action action = ActionTraits::fromJson(bodyJson); // already exists + auto resultState = std::make_shared<::morph::async::detail::CompletionState>(); + handler->template execute(std::move(action)) + .then([resultState](typename ActionTraits::Result result) { + resultState->setValue(ActionTraits::resultToJson(result)); // already exists + }) + .onError([resultState](std::exception_ptr err) { resultState->setException(err); }); + return {resultState, handler->guiExecutor()}; // BridgeHandler exposes its _guiExec +} +``` + +`ActionTraits::fromJson`/`resultToJson` already exist (generated by +`BRIDGE_REGISTER_ACTION_4` for every action) — this closure only chains +them around the real `execute()` call. No new JSON codec, no new +reflection. + +**Registration point:** `BRIDGE_REGISTER_ACTION_4` (in `registry.hpp`) +gains one extra line calling +`morph::model::detail::registerActionExecutorOnce(...)`, mirroring +the existing `registerActionOnce` call right next to it. This is the only +change to the macro; nothing a user writes changes. Existing calls to +`BRIDGE_REGISTER_ACTION` in `lab_model.hpp`, the bank example, and every +test keep compiling unchanged and gain generic-execute registration for +free — `lab_model.hpp` needs zero new lines. + +#### 2. `BridgeHandler::executeJson` (new method, `bridge.hpp`) + +```cpp +::morph::async::Completion executeJson(std::string_view actionType, std::string_view bodyJson) { + return ActionExecuteRegistry::instance().execute( + std::string{ModelTraits::typeId()}, actionType, this, bodyJson); +} +``` + +Thin: looks up the closure, invokes it with `this`. `ModelTraits` +is already required by every `BridgeHandler` instantiation, so no +new constraint is added. Needs a `guiExecutor()` accessor added to +`BridgeHandler` (it already stores `_guiExec`; today nothing outside the +class reads it back). + +#### 3. `FormsController` rewrite (`examples/forms/gui_qml/FormsController.{hpp,cpp}`) + +Drops: `morph::backend::RemoteServer`, `morph::wire::*`, `_modelId`, +`_nextCallId`, `_pool` sized for a manual server. + +Adds: +- `morph::exec::ThreadPoolExecutor _pool` (worker executor, same role as + `bankgui::BankClient::_pool`) and `morph::qt::QtExecutor _gui`. +- `morph::bridge::Bridge _bridge{std::make_unique(_pool)}`. +- `morph::bridge::BridgeHandler _handler{_bridge, &_gui}`. +- `Q_INVOKABLE void submitIfValid(QString actionType, QString bodyJson)` + (called by QML whenever the assembled body is schema-valid, on every + edit) → `_handler.executeJson(actionType.toStdString(), bodyJson.toStdString())`, + `.then()`/`.onError()` emit the existing `replyReceived(actionType, ok, payload)` + signal. Fully generic: no per-action branch, no per-action subscription + to register in the constructor. +- `fetchOptions` stays, backed by `_handler.execute(ListSamples{})` + instead of a hand-built envelope (this one call site *is* per-action — + `ListSamples` is the one fixed, hand-known query the controller always + needs — but it already existed conceptually in the current code and + does not grow with the number of form actions). + +`submit(actionType, bodyJson)` is renamed `submitIfValid` and re-fires on +every call rather than only on a button click; the signal surface to QML +(`replyReceived`, `optionsReceived`) is unchanged. + +#### 4. `DynamicForm.qml` + +`revalidate()` already computes `ready` and assembles `previewLine` (the +full JSON body) on every edit. Add: when `ready` becomes (or stays) `true` +after an edit, call `controller.submitIfValid(actionType, previewLine)` +directly from `revalidate()` — no separate per-field call, since the +execute path takes the whole body already assembled exactly as it is +today. The "execute" `Button` is removed from the reactive path; the +built line (`previewLine`) stays visible as a preview of what was last +sent, and `resultText` updates live as replies stream in via the +unchanged `onReplyReceived` handler. + +#### Error handling + +- Unknown `actionType` (typo, stale schema): `executeJson` throws; + `FormsController::submitIfValid` catches and emits `replyReceived` with + `ok=false` rather than propagating into Qt's slot-invocation exception + boundary (undefined behaviour today if left uncaught). +- Bad/incomplete JSON body: `ActionTraits::fromJson` already + throws `ParseError` on malformed input (existing behaviour, exercised + today via `ActionDispatcher::dispatch`); same catch-and-report path. + The QML side already gates on `revalidate()`'s own regex/required-field + checks before calling `submitIfValid`, so this is a defence-in-depth + path, not the primary validation gate. +- `Model::execute` throwing (e.g. `RecordMeasurement`'s own + `validate()`-based guard): propagates through `Completion::onError` to + `replyReceived(actionType, false, message)` exactly as the current + `sendExecute` error path already does. + +#### Testing + +- New unit test (`tests/test_bridge_execute_json.cpp` or added to + `tests/test_bridge_local.cpp`): registers a test action via + `BRIDGE_REGISTER_ACTION`, calls `BridgeHandler::executeJson` with a JSON + body, asserts the `Completion` resolves with the correctly + JSON-encoded result — and a second case with a malformed body asserting + the completion's `onError` fires with a parse error, matching + `ActionTraits::fromJson`'s existing throw behaviour. +- `examples/forms/gui_qml/tests/tst_main.cpp` (Qt Quick Test) gains a case + driving `DynamicForm` through simulated field edits and asserting + `resultText` updates without any button click. +- Existing `forms_qml_logic` test target and CTest wiring are unchanged. + +### Open questions / risks + +- `void*` type erasure in `ActionExecuteRegistry::Executor` mirrors the + existing untyped-`Runner` pattern in `ActionDispatcher` + (`std::function`) rather + than introducing `std::any`; this keeps the new code consistent with + the codebase's existing type-erasure idiom. +- `executeJson` re-fires the whole action on every keystroke once the + form is valid, with no coalescing: a burst of edits while a call is + in flight will queue multiple concurrent `Bridge::executeVia` calls + (each independently resolving `replyReceived`, last-writer-wins on + `resultText`). This is a real behavioural difference from `set<>`'s + built-in coalescing (which this design deliberately does not use) and + should be noted in the demo's README; if it proves visibly janky in + practice, a debounce timer in `DynamicForm.qml` (fire ~150ms after the + last edit) is a self-contained follow-up, not a blocker for this plan. + +--- + +## Reactive Forms Over BridgeHandler Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. @@ -8,7 +257,7 @@ **Tech Stack:** C++23, glaze (JSON + reflection), Catch2, Qt 6 (Quick/Qml/QuickTest), CMake/Ninja, MSVC (primary) via vcpkg. -## Global Constraints +### Global Constraints - `set<&Action::field>`/`subscribe` (BridgeHandler's compile-time fielded-draft API) is **out of scope** — confirmed unusable generically because glaze's plain-aggregate reflection (`reflectable`, no `glz::meta`) exposes only field names, not compile-time member pointers. Do not attempt to resurrect it inside this plan. - Adding a new action to `lab_model.hpp` (or any `BRIDGE_REGISTER_ACTION` call site) must require **zero new lines** beyond what it needs today. Every task that touches `BRIDGE_REGISTER_ACTION` must preserve this. @@ -19,7 +268,7 @@ --- -## Task 1: `ActionExecuteRegistry` in `bridge.hpp` with a direct unit test +### Task 1: `ActionExecuteRegistry` in `bridge.hpp` with a direct unit test **Files:** - Modify: `include/morph/bridge.hpp` (add `ActionExecuteRegistry` class and `defaultActionExecuteRegistry()` free function, near the top of `namespace morph::bridge`, before `class Bridge`) @@ -332,7 +581,7 @@ EOF --- -## Task 2: Rewrite `FormsController` to use `Bridge`/`BridgeHandler` +### Task 2: Rewrite `FormsController` to use `Bridge`/`BridgeHandler` **Files:** - Modify: `examples/forms/gui_qml/FormsController.hpp` @@ -541,7 +790,7 @@ EOF --- -## Task 3: Make `DynamicForm.qml` reactive (remove the submit button) +### Task 3: Make `DynamicForm.qml` reactive (remove the submit button) **Files:** - Modify: `examples/forms/gui_qml/qml/DynamicForm.qml` @@ -624,7 +873,7 @@ EOF --- -## Task 4: Qt Quick Test coverage for the reactive path +### Task 4: Qt Quick Test coverage for the reactive path **Files:** - Modify: `examples/forms/gui_qml/tests/tst_main.cpp` (check its current structure first — it likely just registers the test directory; see Step 1) @@ -817,7 +1066,7 @@ EOF --- -## Task 5: Update the forms demo README +### Task 5: Update the forms demo README **Files:** - Modify: `examples/forms/README.md` @@ -853,7 +1102,7 @@ EOF --- -## Task 6: Full-suite regression pass and manual end-to-end verification +### Task 6: Full-suite regression pass and manual end-to-end verification **Files:** None modified — verification only. @@ -889,7 +1138,7 @@ No commit for this task (verification only) — if all steps pass, the branch is --- -## Self-Review Notes +### Self-Review Notes - **Spec coverage:** Goals — "Bridge/BridgeHandler only" (Tasks 2-3), "fires automatically, no submit button" (Task 3), "zero new boilerplate per action" (Task 1's registration lives entirely inside `BRIDGE_REGISTER_ACTION_4`; `lab_model.hpp` is never touched in this plan), "`main.cpp` untouched" (no task modifies it) — all covered. Non-goals (`schemaJson` shape, `execute()`'s existing API, options-fetch reactivity, `--emit-html`) — none touched by any task. Testing section's three items (unit test for the registry, QML Quick Test, existing CTest wiring) map to Tasks 1 and 4 respectively. - **Placeholder scan:** The one placeholder-like moment (Task 2 Step 2's first draft calling a nonexistent `lab::ActionTraits_SampleList_toJson`) is deliberately shown and then corrected within the same step with real code (`glz::write_json`), not left as a TBD — kept it in to document *why* the naive approach doesn't work (no `BRIDGE_REGISTER_ACTION` for `SampleList` itself), matching how the spec's own investigation-and-rejection of `set<>` was written up. diff --git a/docs/superpowers/specs/2026-07-06-reactive-forms-bridge-design.md b/docs/superpowers/specs/2026-07-06-reactive-forms-bridge-design.md deleted file mode 100644 index 5524241..0000000 --- a/docs/superpowers/specs/2026-07-06-reactive-forms-bridge-design.md +++ /dev/null @@ -1,240 +0,0 @@ -# Reactive forms demo over BridgeHandler - -Date: 2026-07-06 - -## Problem - -`examples/forms/gui_qml/FormsController` does not use the framework's real -client API. It builds `morph::wire::Envelope`s by hand and feeds them -directly to an in-process `morph::backend::RemoteServer`, bypassing -`Bridge`/`BridgeHandler` entirely. That is internal server-side wire -protocol plumbing — no real client is expected to touch `wire::Envelope` or -`RemoteServer` directly. Every other GUI in this codebase (`examples/bank`) -talks to models exclusively through `BridgeHandler`. - -Separately, the forms demo is not reactive: each form has an explicit -"execute" button; nothing recomputes until the user clicks it. - -**Investigated and rejected:** the framework's field-by-field, auto-fire -API — `BridgeHandler::set<&Action::field>(value)` / `subscribe(cb)` -(used today only in `tests/test_subscription.cpp`) — looked like the -natural fit, since it fires automatically once a draft becomes valid. -It does not work here: `set` requires a real compile-time -pointer-to-member (`double Action::*`) as a template argument, but -`DynamicForm.qml` only knows field names as runtime strings from the JSON -schema. Generating that pointer generically from glaze reflection was -checked directly against glaze's `reflect` implementation -(`glaze/core/reflect.hpp`): for a plain aggregate with no `glz::meta` -specialisation (every action in this codebase, e.g. `RecordMeasurement`), -glaze's `reflectable` reflection path exposes only field **names** -(`keys`) and a `to_tie()`-based tuple of runtime references — no -compile-time member-pointer artifact exists to extract. Producing one -per field would need either C++26 static reflection (not in use here) or -a hand-written `glz::meta` per action declaring each member -pointer explicitly, which reintroduces exactly the per-action boilerplate -this design exists to avoid. `set<>`/`subscribe<>` is therefore left as -what it is today — a compile-time API for hand-written C++ call sites — -and out of scope for the schema-driven QML forms. - -The chosen alternative keeps the *outcome* (fires automatically as the -user types, no button) without needing a field-level compile-time hook: -the controller assembles the full action body as JSON (the QML side -already does this for the current submit button) and calls a new -generic, JSON-in/JSON-out execute path through `Bridge`/`BridgeHandler` -whenever the assembled body is schema-valid. This still requires -resolving a runtime action-type string to a concrete C++ `Action` type to -call `BridgeHandler::execute()` — the same shape of problem, one -level up. It is solved the same way `ActionDispatcher` already solves it -for `RemoteServer` (`registry.hpp`): a static-init-populated map from -action-type string to a type-erased closure, built automatically inside -`BRIDGE_REGISTER_ACTION` (no new macro, no new per-action declaration). - -## Goals - -- `FormsController` talks to `LabModel` exclusively through - `Bridge`/`BridgeHandler` — no `wire::Envelope`, no - `RemoteServer`, no `ActionDispatcher`/`ModelRegistryFactory` singletons - touched from the GUI layer. -- Forms fire automatically as soon as their required fields are valid - (live recompute) — no submit button in the reactive path. -- Zero new boilerplate per action: `lab_model.hpp` (and any future action - file) needs no changes beyond what `BRIDGE_REGISTER_ACTION` already - requires today. -- `examples/forms/main.cpp` (schema dump / `--emit-html` / REPL) is - untouched — it has no GUI and does not use `FormsController`. - -## Non-goals - -- No change to the JSON Schema shape (`morph::forms::schemaJson`) or to - `x-order`/`x-decimalPlaces`/`x-optionsAction` metadata. -- No change to `BridgeHandler`'s existing `execute()`/one-shot API — it - stays as-is for `fetchOptions` (`ListSamples` is a plain query, not a - fielded draft). -- No attempt to make the *options* combo-box fetch reactive; it remains - fetched once per form (as today), triggered from `Component.onCompleted`. -- The static `--emit-html` page (pure JS, no C++ backend at all) is out of - scope; it already works standalone and has its own test - (`test_html_math.mjs`). - -## Design - -### 1. `ActionExecuteRegistry` (new, in `include/morph/bridge.hpp`) - -A process-wide singleton with the same shape as the existing -`ActionDispatcher` (`registry.hpp`): a static-init-populated map, keyed by -`(modelId, actionId)`, of type-erased closures — except this closure calls -through `Bridge::executeVia` (routing, sessions, backend -switches, completions) instead of `ActionDispatcher::Runner`, which calls -`Model::execute` directly against an already-owned `IModelHolder` and is -only ever invoked server-side (inside `RemoteServer`/`ActionDispatcher` -dispatch, never from a client). - -```cpp -class ActionExecuteRegistry { -public: - // Deserialises `bodyJson` into the concrete Action, dispatches it - // through the handler's Bridge, and resolves with the JSON-encoded - // result (or the exception, unchanged) on the handler's gui executor. - using Executor = std::function<::morph::async::Completion(void* /* BridgeHandler* */, - std::string_view bodyJson)>; - - template - void registerAction(std::string_view modelId, std::string_view actionId); - - ::morph::async::Completion execute(std::string_view modelId, std::string_view actionId, - void* handler, std::string_view bodyJson) const; // throws if unknown - - static ActionExecuteRegistry& instance(); -}; -``` - -The registered closure: - -```cpp -[](void* handlerVoid, std::string_view bodyJson) -> ::morph::async::Completion { - auto* handler = static_cast*>(handlerVoid); - Action action = ActionTraits::fromJson(bodyJson); // already exists - auto resultState = std::make_shared<::morph::async::detail::CompletionState>(); - handler->template execute(std::move(action)) - .then([resultState](typename ActionTraits::Result result) { - resultState->setValue(ActionTraits::resultToJson(result)); // already exists - }) - .onError([resultState](std::exception_ptr err) { resultState->setException(err); }); - return {resultState, handler->guiExecutor()}; // BridgeHandler exposes its _guiExec -} -``` - -`ActionTraits::fromJson`/`resultToJson` already exist (generated by -`BRIDGE_REGISTER_ACTION_4` for every action) — this closure only chains -them around the real `execute()` call. No new JSON codec, no new -reflection. - -**Registration point:** `BRIDGE_REGISTER_ACTION_4` (in `registry.hpp`) -gains one extra line calling -`morph::model::detail::registerActionExecutorOnce(...)`, mirroring -the existing `registerActionOnce` call right next to it. This is the only -change to the macro; nothing a user writes changes. Existing calls to -`BRIDGE_REGISTER_ACTION` in `lab_model.hpp`, the bank example, and every -test keep compiling unchanged and gain generic-execute registration for -free — `lab_model.hpp` needs zero new lines. - -### 2. `BridgeHandler::executeJson` (new method, `bridge.hpp`) - -```cpp -::morph::async::Completion executeJson(std::string_view actionType, std::string_view bodyJson) { - return ActionExecuteRegistry::instance().execute( - std::string{ModelTraits::typeId()}, actionType, this, bodyJson); -} -``` - -Thin: looks up the closure, invokes it with `this`. `ModelTraits` -is already required by every `BridgeHandler` instantiation, so no -new constraint is added. Needs a `guiExecutor()` accessor added to -`BridgeHandler` (it already stores `_guiExec`; today nothing outside the -class reads it back). - -### 3. `FormsController` rewrite (`examples/forms/gui_qml/FormsController.{hpp,cpp}`) - -Drops: `morph::backend::RemoteServer`, `morph::wire::*`, `_modelId`, -`_nextCallId`, `_pool` sized for a manual server. - -Adds: -- `morph::exec::ThreadPoolExecutor _pool` (worker executor, same role as - `bankgui::BankClient::_pool`) and `morph::qt::QtExecutor _gui`. -- `morph::bridge::Bridge _bridge{std::make_unique(_pool)}`. -- `morph::bridge::BridgeHandler _handler{_bridge, &_gui}`. -- `Q_INVOKABLE void submitIfValid(QString actionType, QString bodyJson)` - (called by QML whenever the assembled body is schema-valid, on every - edit) → `_handler.executeJson(actionType.toStdString(), bodyJson.toStdString())`, - `.then()`/`.onError()` emit the existing `replyReceived(actionType, ok, payload)` - signal. Fully generic: no per-action branch, no per-action subscription - to register in the constructor. -- `fetchOptions` stays, backed by `_handler.execute(ListSamples{})` - instead of a hand-built envelope (this one call site *is* per-action — - `ListSamples` is the one fixed, hand-known query the controller always - needs — but it already existed conceptually in the current code and - does not grow with the number of form actions). - -`submit(actionType, bodyJson)` is renamed `submitIfValid` and re-fires on -every call rather than only on a button click; the signal surface to QML -(`replyReceived`, `optionsReceived`) is unchanged. - -### 4. `DynamicForm.qml` - -`revalidate()` already computes `ready` and assembles `previewLine` (the -full JSON body) on every edit. Add: when `ready` becomes (or stays) `true` -after an edit, call `controller.submitIfValid(actionType, previewLine)` -directly from `revalidate()` — no separate per-field call, since the -execute path takes the whole body already assembled exactly as it is -today. The "execute" `Button` is removed from the reactive path; the -built line (`previewLine`) stays visible as a preview of what was last -sent, and `resultText` updates live as replies stream in via the -unchanged `onReplyReceived` handler. - -### Error handling - -- Unknown `actionType` (typo, stale schema): `executeJson` throws; - `FormsController::submitIfValid` catches and emits `replyReceived` with - `ok=false` rather than propagating into Qt's slot-invocation exception - boundary (undefined behaviour today if left uncaught). -- Bad/incomplete JSON body: `ActionTraits::fromJson` already - throws `ParseError` on malformed input (existing behaviour, exercised - today via `ActionDispatcher::dispatch`); same catch-and-report path. - The QML side already gates on `revalidate()`'s own regex/required-field - checks before calling `submitIfValid`, so this is a defence-in-depth - path, not the primary validation gate. -- `Model::execute` throwing (e.g. `RecordMeasurement`'s own - `validate()`-based guard): propagates through `Completion::onError` to - `replyReceived(actionType, false, message)` exactly as the current - `sendExecute` error path already does. - -### Testing - -- New unit test (`tests/test_bridge_execute_json.cpp` or added to - `tests/test_bridge_local.cpp`): registers a test action via - `BRIDGE_REGISTER_ACTION`, calls `BridgeHandler::executeJson` with a JSON - body, asserts the `Completion` resolves with the correctly - JSON-encoded result — and a second case with a malformed body asserting - the completion's `onError` fires with a parse error, matching - `ActionTraits::fromJson`'s existing throw behaviour. -- `examples/forms/gui_qml/tests/tst_main.cpp` (Qt Quick Test) gains a case - driving `DynamicForm` through simulated field edits and asserting - `resultText` updates without any button click. -- Existing `forms_qml_logic` test target and CTest wiring are unchanged. - -## Open questions / risks - -- `void*` type erasure in `ActionExecuteRegistry::Executor` mirrors the - existing untyped-`Runner` pattern in `ActionDispatcher` - (`std::function`) rather - than introducing `std::any`; this keeps the new code consistent with - the codebase's existing type-erasure idiom. -- `executeJson` re-fires the whole action on every keystroke once the - form is valid, with no coalescing: a burst of edits while a call is - in flight will queue multiple concurrent `Bridge::executeVia` calls - (each independently resolving `replyReceived`, last-writer-wins on - `resultText`). This is a real behavioural difference from `set<>`'s - built-in coalescing (which this design deliberately does not use) and - should be noted in the demo's README; if it proves visibly janky in - practice, a debounce timer in `DynamicForm.qml` (fire ~150ms after the - last edit) is a self-contained follow-up, not a blocker for this plan. diff --git a/include/morph/bridge.hpp b/include/morph/bridge.hpp index 623f434..0c58065 100644 --- a/include/morph/bridge.hpp +++ b/include/morph/bridge.hpp @@ -49,10 +49,19 @@ class ActionExecuteRegistry { /// @brief Registers the executor for `(Model, Action)` under the given string ids. /// Defined out-of-line after BridgeHandler to avoid forward reference issues. + /// @tparam Model Model type whose handler will execute the action. + /// @tparam Action Action type to register. + /// @param modelId String id the model is registered under. + /// @param actionId String id the action is registered under. template void registerAction(std::string_view modelId, std::string_view actionId); /// @brief Looks up and invokes the executor for `(modelId, actionId)`. + /// @param modelId String id of the target model. + /// @param actionId String id of the action to execute. + /// @param handler Type-erased `BridgeHandler*` matching `modelId`. + /// @param bodyJson JSON-encoded action payload. + /// @return Completion that resolves with the JSON-encoded action result. /// @throws std::runtime_error if no executor was registered for that pair. [[nodiscard]] ::morph::async::Completion execute(std::string_view modelId, std::string_view actionId, void* handler, std::string_view bodyJson) const { @@ -65,6 +74,7 @@ class ActionExecuteRegistry { } /// @brief Returns the process-level singleton registry. + /// @return Reference to the singleton `ActionExecuteRegistry`. static ActionExecuteRegistry& instance(); private: From fc0ae487c0f022e5032f8c87a7f30387ce3f1f3c Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Tue, 7 Jul 2026 18:28:04 +0200 Subject: [PATCH 13/13] docs: compress reactive-forms doc to current behavior, add CLAUDE.md Rewrite the superpowers feature doc to describe only the existing behavior in present tense (no previous-state narration or task checklists), keeping it well under 500 lines. Record the documentation convention and the Doxygen FAIL_ON_WARNINGS local-repro steps in a new repo CLAUDE.md. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 20 + .../2026-07-06-reactive-forms-bridge.md | 1236 ++--------------- 2 files changed, 142 insertions(+), 1114 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dc00632 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,20 @@ +# CLAUDE.md + +## Feature documentation (`docs/superpowers/`) + +One file per feature, compressed reference documentation: + +- Keep each file **under 500 lines**; be concise, not exhaustive. +- Describe **only the existing behavior, in present tense**. Never document + the previous state, migrations, diffs, or task checklists — these files + are not changelogs or implementation plans (git history covers that). +- When a feature changes, rewrite the affected sections to state the new + current behavior. + +## CI notes + +- The Docs workflow runs Doxygen with `WARN_AS_ERROR = FAIL_ON_WARNINGS`: + every public symbol needs complete `@param`/`@tparam`/`@return` docs or + the build fails. Reproduce locally with + `cmake -S . -B build -G Ninja -DMORPH_BUILD_DOCUMENTATION=ON -DMORPH_BUILD_TESTS=OFF -DMORPH_BUILD_EXAMPLES=OFF` + then `cmake --build build --target doc`. diff --git a/docs/superpowers/2026-07-06-reactive-forms-bridge.md b/docs/superpowers/2026-07-06-reactive-forms-bridge.md index a07091a..5d51f55 100644 --- a/docs/superpowers/2026-07-06-reactive-forms-bridge.md +++ b/docs/superpowers/2026-07-06-reactive-forms-bridge.md @@ -1,1145 +1,153 @@ # Reactive Forms over Bridge/BridgeHandler -Date: 2026-07-06 - -Combined superpowers documentation: the design spec followed by the implementation plan (previously `specs/2026-07-06-reactive-forms-bridge-design.md` and `plans/2026-07-06-reactive-forms-bridge.md`). - ---- - -## Reactive forms demo over BridgeHandler - -### Problem - -`examples/forms/gui_qml/FormsController` does not use the framework's real -client API. It builds `morph::wire::Envelope`s by hand and feeds them -directly to an in-process `morph::backend::RemoteServer`, bypassing -`Bridge`/`BridgeHandler` entirely. That is internal server-side wire -protocol plumbing — no real client is expected to touch `wire::Envelope` or -`RemoteServer` directly. Every other GUI in this codebase (`examples/bank`) -talks to models exclusively through `BridgeHandler`. - -Separately, the forms demo is not reactive: each form has an explicit -"execute" button; nothing recomputes until the user clicks it. - -**Investigated and rejected:** the framework's field-by-field, auto-fire -API — `BridgeHandler::set<&Action::field>(value)` / `subscribe(cb)` -(used today only in `tests/test_subscription.cpp`) — looked like the -natural fit, since it fires automatically once a draft becomes valid. -It does not work here: `set` requires a real compile-time -pointer-to-member (`double Action::*`) as a template argument, but -`DynamicForm.qml` only knows field names as runtime strings from the JSON -schema. Generating that pointer generically from glaze reflection was -checked directly against glaze's `reflect` implementation -(`glaze/core/reflect.hpp`): for a plain aggregate with no `glz::meta` -specialisation (every action in this codebase, e.g. `RecordMeasurement`), -glaze's `reflectable` reflection path exposes only field **names** -(`keys`) and a `to_tie()`-based tuple of runtime references — no -compile-time member-pointer artifact exists to extract. Producing one -per field would need either C++26 static reflection (not in use here) or -a hand-written `glz::meta` per action declaring each member -pointer explicitly, which reintroduces exactly the per-action boilerplate -this design exists to avoid. `set<>`/`subscribe<>` is therefore left as -what it is today — a compile-time API for hand-written C++ call sites — -and out of scope for the schema-driven QML forms. - -The chosen alternative keeps the *outcome* (fires automatically as the -user types, no button) without needing a field-level compile-time hook: -the controller assembles the full action body as JSON (the QML side -already does this for the current submit button) and calls a new -generic, JSON-in/JSON-out execute path through `Bridge`/`BridgeHandler` -whenever the assembled body is schema-valid. This still requires -resolving a runtime action-type string to a concrete C++ `Action` type to -call `BridgeHandler::execute()` — the same shape of problem, one -level up. It is solved the same way `ActionDispatcher` already solves it -for `RemoteServer` (`registry.hpp`): a static-init-populated map from -action-type string to a type-erased closure, built automatically inside -`BRIDGE_REGISTER_ACTION` (no new macro, no new per-action declaration). - -### Goals - -- `FormsController` talks to `LabModel` exclusively through - `Bridge`/`BridgeHandler` — no `wire::Envelope`, no - `RemoteServer`, no `ActionDispatcher`/`ModelRegistryFactory` singletons - touched from the GUI layer. -- Forms fire automatically as soon as their required fields are valid - (live recompute) — no submit button in the reactive path. -- Zero new boilerplate per action: `lab_model.hpp` (and any future action - file) needs no changes beyond what `BRIDGE_REGISTER_ACTION` already - requires today. -- `examples/forms/main.cpp` (schema dump / `--emit-html` / REPL) is - untouched — it has no GUI and does not use `FormsController`. - -### Non-goals - -- No change to the JSON Schema shape (`morph::forms::schemaJson`) or to - `x-order`/`x-decimalPlaces`/`x-optionsAction` metadata. -- No change to `BridgeHandler`'s existing `execute()`/one-shot API — it - stays as-is for `fetchOptions` (`ListSamples` is a plain query, not a - fielded draft). -- No attempt to make the *options* combo-box fetch reactive; it remains - fetched once per form (as today), triggered from `Component.onCompleted`. -- The static `--emit-html` page (pure JS, no C++ backend at all) is out of - scope; it already works standalone and has its own test - (`test_html_math.mjs`). - -### Design - -#### 1. `ActionExecuteRegistry` (new, in `include/morph/bridge.hpp`) - -A process-wide singleton with the same shape as the existing -`ActionDispatcher` (`registry.hpp`): a static-init-populated map, keyed by -`(modelId, actionId)`, of type-erased closures — except this closure calls -through `Bridge::executeVia` (routing, sessions, backend -switches, completions) instead of `ActionDispatcher::Runner`, which calls -`Model::execute` directly against an already-owned `IModelHolder` and is -only ever invoked server-side (inside `RemoteServer`/`ActionDispatcher` -dispatch, never from a client). +The QML forms demo (`examples/forms/gui_qml`) talks to `lab::LabModel` +exclusively through `morph::bridge::Bridge`/`BridgeHandler` — the +framework's real client API. Forms are reactive: an action fires +automatically as soon as its required fields are valid, on every edit; there +is no submit button. Adding a new action to `lab_model.hpp` (or any +`BRIDGE_REGISTER_ACTION` call site) requires zero lines beyond the +registration itself — no per-action GUI code exists anywhere. + +## Architecture + +``` +DynamicForm.qml ──revalidate()──▶ FormsController::submitIfValid(actionType, bodyJson) + │ + ▼ + BridgeHandler::executeJson(actionType, bodyJson) + │ (string → closure lookup) + ▼ + ActionExecuteRegistry::execute(modelId, actionId, handler, body) + │ (type-erased closure, registered by + │ BRIDGE_REGISTER_ACTION at static init) + ▼ + BridgeHandler::execute(action) + │ (real compile-time path: sessions, + │ backend switches, completions) + ▼ + Bridge ─▶ LocalBackend ─▶ LabModel::execute +``` + +### `ActionExecuteRegistry` (`include/morph/bridge.hpp`) + +A process-wide singleton mapping `(modelId, actionId)` strings to +type-erased executors. It is what lets QML dispatch actions it only knows +by name: ```cpp class ActionExecuteRegistry { public: - // Deserialises `bodyJson` into the concrete Action, dispatches it - // through the handler's Bridge, and resolves with the JSON-encoded - // result (or the exception, unchanged) on the handler's gui executor. using Executor = std::function<::morph::async::Completion(void* /* BridgeHandler* */, std::string_view bodyJson)>; - template void registerAction(std::string_view modelId, std::string_view actionId); + // Throws std::runtime_error if no executor is registered for the pair. ::morph::async::Completion execute(std::string_view modelId, std::string_view actionId, - void* handler, std::string_view bodyJson) const; // throws if unknown + void* handler, std::string_view bodyJson) const; static ActionExecuteRegistry& instance(); }; ``` -The registered closure: +Each registered closure deserialises the JSON body via +`ActionTraits::fromJson`, calls the real +`BridgeHandler::execute()` (so sessions, backend switches, +and completions behave exactly as at hand-written call sites), and +re-serialises the result via `ActionTraits::resultToJson`. It +resolves on the handler's GUI executor (`BridgeHandler::guiExecutor()`). -```cpp -[](void* handlerVoid, std::string_view bodyJson) -> ::morph::async::Completion { - auto* handler = static_cast*>(handlerVoid); - Action action = ActionTraits::fromJson(bodyJson); // already exists - auto resultState = std::make_shared<::morph::async::detail::CompletionState>(); - handler->template execute(std::move(action)) - .then([resultState](typename ActionTraits::Result result) { - resultState->setValue(ActionTraits::resultToJson(result)); // already exists - }) - .onError([resultState](std::exception_ptr err) { resultState->setException(err); }); - return {resultState, handler->guiExecutor()}; // BridgeHandler exposes its _guiExec -} -``` +Registration happens inside `BRIDGE_REGISTER_ACTION_4` +(`include/morph/registry.hpp`) via +`morph::model::detail::registerActionExecutorOnce`, next to +the existing server-side `registerActionOnce` call. Every action registered +with `BRIDGE_REGISTER_ACTION` therefore has a generic-execute entry +automatically. -`ActionTraits::fromJson`/`resultToJson` already exist (generated by -`BRIDGE_REGISTER_ACTION_4` for every action) — this closure only chains -them around the real `execute()` call. No new JSON codec, no new -reflection. +**Hard requirement:** `registerActionExecutorOnce` is only defined in +`bridge.hpp`, so every translation unit that calls `BRIDGE_REGISTER_ACTION` +must include `bridge.hpp` (directly or transitively), or its static +initializer fails to link. -**Registration point:** `BRIDGE_REGISTER_ACTION_4` (in `registry.hpp`) -gains one extra line calling -`morph::model::detail::registerActionExecutorOnce(...)`, mirroring -the existing `registerActionOnce` call right next to it. This is the only -change to the macro; nothing a user writes changes. Existing calls to -`BRIDGE_REGISTER_ACTION` in `lab_model.hpp`, the bank example, and every -test keep compiling unchanged and gain generic-execute registration for -free — `lab_model.hpp` needs zero new lines. +This registry is the client-side counterpart of +`morph::model::detail::ActionDispatcher` (`registry.hpp`), which calls +`Model::execute` directly against an owned model holder and is only used +server-side. Both use the same `void*`/`std::function` type-erasure idiom. -#### 2. `BridgeHandler::executeJson` (new method, `bridge.hpp`) +### `BridgeHandler::executeJson` (`bridge.hpp`) ```cpp -::morph::async::Completion executeJson(std::string_view actionType, std::string_view bodyJson) { - return ActionExecuteRegistry::instance().execute( - std::string{ModelTraits::typeId()}, actionType, this, bodyJson); -} +::morph::async::Completion executeJson(std::string_view actionType, std::string_view bodyJson); ``` -Thin: looks up the closure, invokes it with `this`. `ModelTraits` -is already required by every `BridgeHandler` instantiation, so no -new constraint is added. Needs a `guiExecutor()` accessor added to -`BridgeHandler` (it already stores `_guiExec`; today nothing outside the -class reads it back). +Thin wrapper: looks up the executor under +`(ModelTraits::typeId(), actionType)` and invokes it with `this`. -#### 3. `FormsController` rewrite (`examples/forms/gui_qml/FormsController.{hpp,cpp}`) +### `FormsController` (`examples/forms/gui_qml/FormsController.{hpp,cpp}`) -Drops: `morph::backend::RemoteServer`, `morph::wire::*`, `_modelId`, -`_nextCallId`, `_pool` sized for a manual server. +Owns the client stack: -Adds: -- `morph::exec::ThreadPoolExecutor _pool` (worker executor, same role as - `bankgui::BankClient::_pool`) and `morph::qt::QtExecutor _gui`. +- `morph::exec::ThreadPoolExecutor _pool` (worker) and + `morph::qt::QtExecutor _gui` (GUI-thread delivery). - `morph::bridge::Bridge _bridge{std::make_unique(_pool)}`. - `morph::bridge::BridgeHandler _handler{_bridge, &_gui}`. -- `Q_INVOKABLE void submitIfValid(QString actionType, QString bodyJson)` - (called by QML whenever the assembled body is schema-valid, on every - edit) → `_handler.executeJson(actionType.toStdString(), bodyJson.toStdString())`, - `.then()`/`.onError()` emit the existing `replyReceived(actionType, ok, payload)` - signal. Fully generic: no per-action branch, no per-action subscription - to register in the constructor. -- `fetchOptions` stays, backed by `_handler.execute(ListSamples{})` - instead of a hand-built envelope (this one call site *is* per-action — - `ListSamples` is the one fixed, hand-known query the controller always - needs — but it already existed conceptually in the current code and - does not grow with the number of form actions). - -`submit(actionType, bodyJson)` is renamed `submitIfValid` and re-fires on -every call rather than only on a button click; the signal surface to QML -(`replyReceived`, `optionsReceived`) is unchanged. - -#### 4. `DynamicForm.qml` - -`revalidate()` already computes `ready` and assembles `previewLine` (the -full JSON body) on every edit. Add: when `ready` becomes (or stays) `true` -after an edit, call `controller.submitIfValid(actionType, previewLine)` -directly from `revalidate()` — no separate per-field call, since the -execute path takes the whole body already assembled exactly as it is -today. The "execute" `Button` is removed from the reactive path; the -built line (`previewLine`) stays visible as a preview of what was last -sent, and `resultText` updates live as replies stream in via the -unchanged `onReplyReceived` handler. - -#### Error handling - -- Unknown `actionType` (typo, stale schema): `executeJson` throws; - `FormsController::submitIfValid` catches and emits `replyReceived` with - `ok=false` rather than propagating into Qt's slot-invocation exception - boundary (undefined behaviour today if left uncaught). -- Bad/incomplete JSON body: `ActionTraits::fromJson` already - throws `ParseError` on malformed input (existing behaviour, exercised - today via `ActionDispatcher::dispatch`); same catch-and-report path. - The QML side already gates on `revalidate()`'s own regex/required-field - checks before calling `submitIfValid`, so this is a defence-in-depth - path, not the primary validation gate. -- `Model::execute` throwing (e.g. `RecordMeasurement`'s own - `validate()`-based guard): propagates through `Completion::onError` to - `replyReceived(actionType, false, message)` exactly as the current - `sendExecute` error path already does. - -#### Testing - -- New unit test (`tests/test_bridge_execute_json.cpp` or added to - `tests/test_bridge_local.cpp`): registers a test action via - `BRIDGE_REGISTER_ACTION`, calls `BridgeHandler::executeJson` with a JSON - body, asserts the `Completion` resolves with the correctly - JSON-encoded result — and a second case with a malformed body asserting - the completion's `onError` fires with a parse error, matching - `ActionTraits::fromJson`'s existing throw behaviour. -- `examples/forms/gui_qml/tests/tst_main.cpp` (Qt Quick Test) gains a case - driving `DynamicForm` through simulated field edits and asserting - `resultText` updates without any button click. -- Existing `forms_qml_logic` test target and CTest wiring are unchanged. - -### Open questions / risks - -- `void*` type erasure in `ActionExecuteRegistry::Executor` mirrors the - existing untyped-`Runner` pattern in `ActionDispatcher` - (`std::function`) rather - than introducing `std::any`; this keeps the new code consistent with - the codebase's existing type-erasure idiom. -- `executeJson` re-fires the whole action on every keystroke once the - form is valid, with no coalescing: a burst of edits while a call is - in flight will queue multiple concurrent `Bridge::executeVia` calls - (each independently resolving `replyReceived`, last-writer-wins on - `resultText`). This is a real behavioural difference from `set<>`'s - built-in coalescing (which this design deliberately does not use) and - should be noted in the demo's README; if it proves visibly janky in - practice, a debounce timer in `DynamicForm.qml` (fire ~150ms after the - last edit) is a self-contained follow-up, not a blocker for this plan. - ---- - -## Reactive Forms Over BridgeHandler Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the QML forms demo (`examples/forms/gui_qml`) talk to `LabModel` through `morph::bridge::Bridge`/`BridgeHandler` instead of hand-built `wire::Envelope`s over `RemoteServer`, and make forms fire automatically as the user types (no submit button) — with zero new C++ declarations required per action. - -**Architecture:** A new `ActionExecuteRegistry` (in `include/morph/bridge.hpp`, alongside `Bridge`/`BridgeHandler`) maps `(modelId, actionId)` strings to type-erased closures that deserialize a JSON body, call the real compile-time `BridgeHandler::execute()`, and re-serialize the result. It is populated automatically inside the existing `BRIDGE_REGISTER_ACTION_4` macro (`include/morph/registry.hpp`) — every already-registered action gains a generic-execute entry for free. `BridgeHandler` gains one new method, `executeJson(actionType, bodyJson) -> Completion`, that looks up and invokes the registered closure. `FormsController` is rewritten to own a `Bridge` + `BridgeHandler` and exposes a single generic `Q_INVOKABLE submitIfValid(actionType, bodyJson)` entry point; `DynamicForm.qml` calls it from `revalidate()` on every edit once the assembled body is valid, replacing the explicit "execute" button. - -**Tech Stack:** C++23, glaze (JSON + reflection), Catch2, Qt 6 (Quick/Qml/QuickTest), CMake/Ninja, MSVC (primary) via vcpkg. - -### Global Constraints - -- `set<&Action::field>`/`subscribe` (BridgeHandler's compile-time fielded-draft API) is **out of scope** — confirmed unusable generically because glaze's plain-aggregate reflection (`reflectable`, no `glz::meta`) exposes only field names, not compile-time member pointers. Do not attempt to resurrect it inside this plan. -- Adding a new action to `lab_model.hpp` (or any `BRIDGE_REGISTER_ACTION` call site) must require **zero new lines** beyond what it needs today. Every task that touches `BRIDGE_REGISTER_ACTION` must preserve this. -- `examples/forms/main.cpp` (schema dump / `--emit-html` / REPL) is untouched — no GUI, does not use `FormsController`. -- No change to `morph::forms::schemaJson` output or its `x-*` extension keys. -- Type erasure in new registries follows the existing `ActionDispatcher::Runner` idiom (`std::function` over a `void*`/`IModelHolder&`), not `std::any` — match `include/morph/registry.hpp`'s existing style. -- All new C++ files use `// SPDX-License-Identifier: Apache-2.0` as the first line, matching every existing header/source in this repo. - ---- - -### Task 1: `ActionExecuteRegistry` in `bridge.hpp` with a direct unit test - -**Files:** -- Modify: `include/morph/bridge.hpp` (add `ActionExecuteRegistry` class and `defaultActionExecuteRegistry()` free function, near the top of `namespace morph::bridge`, before `class Bridge`) -- Modify: `include/morph/registry.hpp` (add `registerActionExecutorOnce` helper in `namespace morph::model::detail`, and one call to it inside `BRIDGE_REGISTER_ACTION_4`) -- Test: `tests/test_bridge_execute_json.cpp` (new) -- Modify: `tests/CMakeLists.txt` (add the new test file to `add_executable(morph_tests ...)`) - -**Interfaces:** -- Consumes: `morph::bridge::Bridge`, `morph::bridge::BridgeHandler::execute()` (existing, `include/morph/bridge.hpp:412-415`), `morph::model::ActionTraits::fromJson`/`resultToJson` (existing, generated by `BRIDGE_REGISTER_ACTION_4`), `morph::async::Completion` (existing, `include/morph/completion.hpp`). -- Produces: `morph::bridge::ActionExecuteRegistry` singleton with `execute(modelId, actionId, handlerVoid, bodyJson) -> Completion` (throws `std::runtime_error` if `(modelId, actionId)` is unregistered). `morph::model::detail::registerActionExecutorOnce(modelId, actionId)` — called automatically from `BRIDGE_REGISTER_ACTION_4`, not meant to be called by hand. - -Note on layering: `registry.hpp` is included by `bridge.hpp` today (`bridge.hpp:19` includes `"registry.hpp"`), so `bridge.hpp` can freely reference `morph::model::detail` types. `registry.hpp` must NOT include `bridge.hpp` back (would cycle) — `registerActionExecutorOnce` in `registry.hpp` calls into `morph::bridge::ActionExecuteRegistry::instance()` via a forward declaration plus an out-of-line definition placed in `bridge.hpp` after both classes are visible. See Step 3 for the exact split. - -- [ ] **Step 1: Write the failing test** - -Create `tests/test_bridge_execute_json.cpp`: - -```cpp -// SPDX-License-Identifier: Apache-2.0 - -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include "test_support.hpp" - -namespace { - -struct AddNumbers { - int a = 0; - int b = 0; -}; - -struct AddResult { - int sum = 0; -}; - -struct MathModel { - AddResult execute(const AddNumbers& action) { return AddResult{.sum = action.a + action.b}; } -}; - -} // namespace - -BRIDGE_REGISTER_MODEL(MathModel, "Test_ExecJson_MathModel") -BRIDGE_REGISTER_ACTION(MathModel, AddNumbers, "Test_ExecJson_AddNumbers") - -using SyncExecutor = morph::testing::InlineExecutor; - -TEST_CASE("ActionExecuteRegistry: executeJson deserialises, executes, and re-serialises", "[bridge][execute-json]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - std::optional resultJson; - std::atomic done{false}; - handler.executeJson("Test_ExecJson_AddNumbers", R"({"a":3,"b":4})") - .then([&](std::string json) { - resultJson = std::move(json); - done.store(true); - }) - .onError([&](const std::exception_ptr&) { done.store(true); }); - - REQUIRE(morph::testing::waitUntil([&] { return done.load(); })); - REQUIRE(resultJson.has_value()); - REQUIRE(*resultJson == R"({"sum":7})"); -} - -TEST_CASE("ActionExecuteRegistry: executeJson reports parse errors via onError", "[bridge][execute-json]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - bool sawError = false; - std::atomic done{false}; - handler.executeJson("Test_ExecJson_AddNumbers", R"({"a":"not a number"})") - .then([&](std::string) { done.store(true); }) - .onError([&](const std::exception_ptr&) { - sawError = true; - done.store(true); - }); - - REQUIRE(morph::testing::waitUntil([&] { return done.load(); })); - REQUIRE(sawError); -} - -TEST_CASE("ActionExecuteRegistry: unknown action type throws", "[bridge][execute-json]") { - morph::exec::ThreadPoolExecutor pool{2}; - SyncExecutor cbExec; - morph::bridge::Bridge bridge{std::make_unique(pool)}; - morph::bridge::BridgeHandler handler{bridge, &cbExec}; - - REQUIRE_THROWS_AS(handler.executeJson("NoSuchAction", "{}"), std::runtime_error); -} -``` - -Check `tests/test_support.hpp` exposes `waitUntil` (confirmed present at `tests/test_support.hpp:40` as `template void waitUntil(Pred, budget = kDefaultWaitBudget)` — if the exact name differs, use whatever polling helper is already defined there instead of adding a new one). - -- [ ] **Step 2: Add the new test file to the test target** - -In `tests/CMakeLists.txt`, add `test_bridge_execute_json.cpp` to the `add_executable(morph_tests ...)` list (alphabetical-ish position doesn't matter — follow the existing loose grouping, e.g. right after `test_bridge_remote.cpp`): - -```cmake - test_bridge_local.cpp - test_bridge_remote.cpp - test_bridge_execute_json.cpp - test_remote_extra.cpp -``` - -- [ ] **Step 3: Run the test to verify it fails to compile** - -Run: `cmake --build build/cl-debug --target morph_tests` -Expected: FAIL — `'executeJson': is not a member of 'morph::bridge::BridgeHandler'` (or similar "no member" compiler error). If `build/cl-debug` does not exist yet, configure it first: `cmake --preset cl-debug` (requires `VCPKG_ROOT` set to the vcpkg install, e.g. `C:/Program Files/Microsoft Visual Studio/18/Professional/VC/vcpkg`). - -- [ ] **Step 4: Implement `ActionExecuteRegistry` in `bridge.hpp`** - -In `include/morph/bridge.hpp`, add right after the `namespace morph::bridge {` line and before `namespace detail {` (i.e. before line 24's `namespace detail {` block): - -```cpp -namespace morph::bridge { - -/// @brief Type-erased, JSON-in/JSON-out execute path for actions whose -/// concrete C++ type is only known by its registered string id at -/// the call site (e.g. a schema-driven GUI that reads action names -/// out of a JSON Schema at runtime). -/// -/// Populated automatically by `BRIDGE_REGISTER_ACTION` — no action-specific -/// code is required at any call site. Every entry calls through the real -/// `BridgeHandler::execute()` (so sessions, backend -/// switches, and completions all behave exactly as they do for hand-written -/// call sites), unlike `morph::model::detail::ActionDispatcher`, which -/// calls `Model::execute` directly against an already-owned model holder -/// and is only ever used server-side. -class ActionExecuteRegistry { -public: - /// @brief Deserialises `bodyJson`, dispatches through the handler's - /// `Bridge`, and resolves with the JSON-encoded result. - using Executor = std::function<::morph::async::Completion(void*, std::string_view)>; - - /// @brief Registers the executor for `(Model, Action)` under the given string ids. - template - void registerAction(std::string_view modelId, std::string_view actionId) { - Key key{std::string{modelId}, std::string{actionId}}; - _executors[key] = [](void* handlerVoid, std::string_view bodyJson) -> ::morph::async::Completion { - auto* handler = static_cast*>(handlerVoid); - auto resultState = std::make_shared<::morph::async::detail::CompletionState>(); - Action action = ::morph::model::ActionTraits::fromJson(bodyJson); - handler->template execute(std::move(action)) - .then([resultState](typename ::morph::model::ActionTraits::Result result) { - resultState->setValue(::morph::model::ActionTraits::resultToJson(result)); - }) - .onError([resultState](const std::exception_ptr& err) { resultState->setException(err); }); - return {resultState, handler->guiExecutor()}; - }; - } - - /// @brief Looks up and invokes the executor for `(modelId, actionId)`. - /// @throws std::runtime_error if no executor was registered for that pair. - [[nodiscard]] ::morph::async::Completion execute(std::string_view modelId, std::string_view actionId, - void* handler, std::string_view bodyJson) const { - auto iter = _executors.find(Key{std::string{modelId}, std::string{actionId}}); - if (iter == _executors.end()) { - throw std::runtime_error("unknown action for executeJson: " + std::string{modelId} + "/" + - std::string{actionId}); - } - return iter->second(handler, bodyJson); - } - - /// @brief Returns the process-level singleton registry. - static ActionExecuteRegistry& instance(); - -private: - using Key = std::pair; - struct KeyHash { - std::size_t operator()(const Key& key) const noexcept { - std::size_t seed = std::hash{}(key.first); - seed ^= std::hash{}(key.second) + 0x9e3779b9 + (seed << 6) + (seed >> 2); - return seed; - } - }; - std::unordered_map _executors; -}; - -inline ActionExecuteRegistry& ActionExecuteRegistry::instance() { - static ActionExecuteRegistry inst; - return inst; -} - -namespace detail { -``` - -This inserts the new class inside the existing `namespace morph::bridge { ... namespace detail { ... } }` structure — `ActionExecuteRegistry` sits in `morph::bridge` directly (sibling of `Bridge`, `BridgeHandler`), and the pre-existing `namespace detail { ... }` block (starting at what was line 24) continues unchanged right after it. - -`bridge.hpp` already includes ``, ``, ``, ``, ``, `` (see its existing include list, lines 4-15) and `"registry.hpp"` (line 19) — no new includes needed for this step. `` is also already included (line 10), covering `std::runtime_error`. - -- [ ] **Step 5: Add `BridgeHandler::executeJson` and `guiExecutor()`** - -In `include/morph/bridge.hpp`, inside `class BridgeHandler` (the existing class, currently ending around line 486-598), add two public methods right after the existing `execute` method (which ends at what is currently line 415, just before `subscribe`): - -```cpp - /// @brief Type-erased execute: looks up the action by its registered - /// string id and dispatches it through `ActionExecuteRegistry`. - /// - /// Use this only when the concrete `Action` type is not known at the - /// call site (e.g. a schema-driven UI reading action names out of a - /// JSON Schema at runtime). Prefer the templated `execute()` - /// whenever the type is known at compile time. - /// - /// @param actionType Registered action type-id (the `NAME` passed to `BRIDGE_REGISTER_ACTION`). - /// @param bodyJson JSON-encoded action body. - /// @return Completion resolving with the JSON-encoded result. - /// @throws std::runtime_error if `actionType` was never registered for `Model`. - [[nodiscard]] ::morph::async::Completion executeJson(std::string_view actionType, - std::string_view bodyJson) { - return ActionExecuteRegistry::instance().execute( - std::string{::morph::model::ModelTraits::typeId()}, actionType, this, bodyJson); - } - - /// @brief The executor used to deliver this handler's `Completion` callbacks. - /// @return The GUI/callback executor passed at construction. - [[nodiscard]] ::morph::exec::IExecutor* guiExecutor() const noexcept { return _guiExec; } -``` - -- [ ] **Step 6: Register the executor automatically inside `BRIDGE_REGISTER_ACTION_4`** - -In `include/morph/registry.hpp`, add a new detail helper right after `registerActionOnce` (currently lines 286-291): - -```cpp -/// @brief Static-init helper for `BRIDGE_REGISTER_ACTION`'s generic-execute registration. -/// -/// Defined out-of-line in `bridge.hpp` (after `ActionExecuteRegistry` is -/// visible) to avoid a `registry.hpp` -> `bridge.hpp` include cycle; -/// `bridge.hpp` already includes `registry.hpp`, not the other way round. -template -inline bool registerActionExecutorOnce(std::string_view modelId, std::string_view actionId) noexcept; -``` - -Then in `BRIDGE_REGISTER_ACTION_4` (currently lines 344-382), add one line to the generated `namespace { ... }` block, right after the existing `bridge_action_reg_##M##_##A` line: - -```cpp - namespace { \ - [[maybe_unused]] const bool bridge_action_reg_##M##_##A = \ - morph::model::detail::registerActionOnce(morph::model::ModelTraits::typeId(), NAME); \ - [[maybe_unused]] const bool bridge_action_exec_reg_##M##_##A = \ - morph::model::detail::registerActionExecutorOnce(morph::model::ModelTraits::typeId(), NAME); \ - } -``` - -Now, in `include/morph/bridge.hpp`, define the out-of-line body — add this right after the `ActionExecuteRegistry` class and its `instance()` definition (from Step 4), still before the pre-existing `namespace detail {` line: - -```cpp -} // namespace morph::bridge - -namespace morph::model::detail { - -template -inline bool registerActionExecutorOnce(std::string_view modelId, std::string_view actionId) noexcept { - ::morph::bridge::ActionExecuteRegistry::instance().registerAction(modelId, actionId); - return true; -} - -} // namespace morph::model::detail - -namespace morph::bridge { - -namespace detail { -``` - -(This briefly closes and reopens `namespace morph::bridge` around the `morph::model::detail` block — matching the file's existing style of nested `namespace X { ... }` blocks rather than fully-qualified names for a multi-line definition.) - -**Important ordering constraint:** `registry.hpp`'s declaration of `registerActionExecutorOnce` (a template) only needs to be *declared* before `BRIDGE_REGISTER_ACTION_4` expands (macro text is checked at the point of use, i.e. wherever `BRIDGE_REGISTER_ACTION` is called in user code, which always includes `bridge.hpp` transitively — confirm this holds for `lab_model.hpp`: it includes `` directly per its current include list, and any translation unit calling `BRIDGE_REGISTER_ACTION` that also wants `Bridge`/`BridgeHandler` already includes `bridge.hpp`). Since `registry.hpp` only needs the *declaration* (function bodies are resolved at link time / are `inline`), and `bridge.hpp` includes `registry.hpp`, the definition in `bridge.hpp` is visible to any TU that includes `bridge.hpp` — which every `BRIDGE_REGISTER_ACTION` call site in this plan's scope (`lab_model.hpp`, the new test file) already does or will do. If a future action file calls `BRIDGE_REGISTER_ACTION` without including `bridge.hpp`, it will fail to link (undefined reference to `registerActionExecutorOnce`) — note this constraint in the doc comment on `registerActionExecutorOnce`'s declaration in `registry.hpp` (already drafted above) and additionally confirm in Step 7 that `lab_model.hpp` needs no include changes because `FormsController.cpp` (Task 3) will include `bridge.hpp` in the same binary. - -- [ ] **Step 7: Build and run the test** - -Run: `cmake --build build/cl-debug --target morph_tests` -Expected: builds cleanly. - -Run: `ctest --test-dir build/cl-debug -R "execute-json" --output-on-failure` -Expected: all 3 new test cases PASS. - -- [ ] **Step 8: Run the full existing test suite to check for regressions** - -Run: `ctest --test-dir build/cl-debug --output-on-failure` -Expected: all tests pass (same pass count as before this change, plus the 3 new ones). This exercises every existing `BRIDGE_REGISTER_ACTION` call site in the test suite, confirming the macro change is backward compatible. - -- [ ] **Step 9: Commit** - -```bash -git add include/morph/bridge.hpp include/morph/registry.hpp tests/test_bridge_execute_json.cpp tests/CMakeLists.txt -git commit -m "$(cat <<'EOF' -feat: add ActionExecuteRegistry for JSON-in/JSON-out action dispatch - -BridgeHandler::executeJson(actionType, bodyJson) resolves a runtime -action-type string to the real compile-time execute() call, -registered automatically inside BRIDGE_REGISTER_ACTION. Lets a -schema-driven UI dispatch through Bridge/BridgeHandler without knowing -concrete C++ action types at compile time, with zero new declarations -per action. -EOF -)" -``` - ---- - -### Task 2: Rewrite `FormsController` to use `Bridge`/`BridgeHandler` - -**Files:** -- Modify: `examples/forms/gui_qml/FormsController.hpp` -- Modify: `examples/forms/gui_qml/FormsController.cpp` - -**Interfaces:** -- Consumes: `morph::bridge::Bridge`, `morph::bridge::BridgeHandler::executeJson` (Task 1), `morph::bridge::BridgeHandler::execute()` (existing), `morph::backend::LocalBackend` (existing, `include/morph/backend.hpp:148-152`), `morph::qt::QtExecutor` (existing, `include/morph/qt/qt_executor.hpp`). -- Produces: `FormsController` with the same public signal surface as before (`replyReceived(actionType, ok, payload)`, `optionsReceived(optionsAction, ok, payload)`, `schemasJson` property) plus a renamed invokable `submitIfValid(actionType, bodyJson)` replacing `submit(actionType, bodyJson)`. `fetchOptions(optionsAction)` keeps its existing signature. - -- [ ] **Step 1: Update `FormsController.hpp`** - -Replace the full contents of `examples/forms/gui_qml/FormsController.hpp`: - -```cpp -// SPDX-License-Identifier: Apache-2.0 -#pragma once - -/// @file -/// QML-facing controller for the schema-driven forms demo. -/// -/// The QML layer renders forms from the schemas exposed here and submits -/// fully-assembled action bodies as JSON strings via `submitIfValid`, -/// called on every edit once the body validates client-side (no submit -/// button). This controller dispatches through a real `morph::bridge::Bridge` -/// + `BridgeHandler` — the same client API `examples/bank`'s GUI -/// uses — via the generic `BridgeHandler::executeJson` path, so it never -/// touches `morph::wire::Envelope` or `morph::backend::RemoteServer` -/// directly. - -#include -#include -#include - -// Guarded like examples/bank/gui/controllers/AccountController.hpp: MOC -// only needs the Q_OBJECT/QML_ELEMENT macros above and the Q_INVOKABLE/ -// Q_PROPERTY declarations below; it must not be pointed at morph's -// template-heavy headers (bridge.hpp, glaze) or the Qt executor. -#ifndef Q_MOC_RUN -#include -#include -#include - -#include "lab_model.hpp" -#endif - -class FormsController : public QObject { - Q_OBJECT - QML_ELEMENT - - /// @brief `{actionType: schema}` JSON — everything the QML renderer needs. - Q_PROPERTY(QString schemasJson READ schemasJson CONSTANT) - -public: - explicit FormsController(QObject* parent = nullptr); - - [[nodiscard]] QString schemasJson() const; - - /// @brief Dispatches @p bodyJson as the body of @p actionType if the - /// body is complete. Called by QML on every field edit once the - /// assembled body passes client-side validation — there is no - /// separate submit step. The reply arrives via `replyReceived` - /// on the GUI thread. - Q_INVOKABLE void submitIfValid(const QString& actionType, const QString& bodyJson); - - /// @brief Executes @p optionsAction with an empty body to fetch combo-box - /// options (a `Choice` field's declared provider). The reply - /// arrives via `optionsReceived` on the GUI thread. - Q_INVOKABLE void fetchOptions(const QString& optionsAction); - -signals: - /// @brief Emitted once per `submitIfValid` call. @p payload is the - /// result JSON when @p ok, otherwise the error message. - void replyReceived(const QString& actionType, bool ok, const QString& payload); - - /// @brief Emitted once per `fetchOptions`. @p payload is the options - /// action's result JSON when @p ok, otherwise the error message. - void optionsReceived(const QString& optionsAction, bool ok, const QString& payload); - -private: - // Declaration order matters for destruction: `_handler` and `_bridge` - // must be torn down before `_pool`/`_gui`, and `_pool` must outlive the - // `LocalBackend` owned inside `_bridge` (constructed from it). Declared - // in construction order so default destruction (reverse order) is safe. - morph::exec::ThreadPoolExecutor _pool{2}; - morph::qt::QtExecutor _gui; - morph::bridge::Bridge _bridge; - morph::bridge::BridgeHandler _handler; -}; -``` - -- [ ] **Step 2: Update `FormsController.cpp`** - -Replace the full contents of `examples/forms/gui_qml/FormsController.cpp`: - -```cpp -// SPDX-License-Identifier: Apache-2.0 - -#include "FormsController.hpp" - -#include -#include -#include - -#include - -#include "lab_schemas.hpp" - -namespace { - -QString errorText(const std::exception_ptr& err) { - try { - if (err) { - std::rethrow_exception(err); - } - } catch (const std::exception& exc) { - return QString::fromUtf8(exc.what()); - } catch (...) { - return QStringLiteral("unknown error"); - } - return {}; -} - -} // namespace - -FormsController::FormsController(QObject* parent) - : QObject{parent}, - _bridge{std::make_unique(_pool)}, - _handler{_bridge, &_gui} {} - -QString FormsController::schemasJson() const { - return QString::fromStdString(lab::schemasJson()); -} - -void FormsController::submitIfValid(const QString& actionType, const QString& bodyJson) { - auto const actionTypeStd = actionType.toStdString(); - _handler.executeJson(actionTypeStd, bodyJson.toStdString()) - .then([this, actionType](std::string resultJson) { - emit replyReceived(actionType, true, QString::fromStdString(resultJson)); - }) - .onError([this, actionType](const std::exception_ptr& err) { - emit replyReceived(actionType, false, errorText(err)); - }); -} - -void FormsController::fetchOptions(const QString& optionsAction) { - _handler.execute(lab::ListSamples{}) - .then([this, optionsAction](lab::SampleList list) { - std::string json = lab::ActionTraits_SampleList_toJson(list); - emit optionsReceived(optionsAction, true, QString::fromStdString(json)); - }) - .onError([this, optionsAction](const std::exception_ptr& err) { - emit optionsReceived(optionsAction, false, errorText(err)); - }); -} -``` - -The `fetchOptions` body above has a placeholder call (`lab::ActionTraits_SampleList_toJson`) that does not exist — fix it in the next step by using the registered `ActionTraits` codec directly, since `SampleList` (the result of `ListSamples`) is not itself a registered action type and has no `BRIDGE_REGISTER_ACTION`-generated `toJson`. Use `glz::write_json` directly instead: - -Replace the `fetchOptions` body with: - -```cpp -void FormsController::fetchOptions(const QString& optionsAction) { - _handler.execute(lab::ListSamples{}) - .then([this, optionsAction](lab::SampleList list) { - std::string json; - if (glz::write_json(list, json)) { - emit optionsReceived(optionsAction, false, QStringLiteral("failed to encode options")); - return; - } - emit optionsReceived(optionsAction, true, QString::fromStdString(json)); - }) - .onError([this, optionsAction](const std::exception_ptr& err) { - emit optionsReceived(optionsAction, false, errorText(err)); - }); -} -``` - -This needs `#include ` added to the top of `FormsController.cpp`'s include list (alongside the others). - -**Note on `optionsJson()`'s shape:** the previous `fetchOptions` (via `RemoteServer`) returned the result of executing `ListSamples` wrapped as `{"samples": [...]}` (matching `lab::SampleList`'s own field name) — `glz::write_json(list, json)` produces exactly the same shape (`{"samples":[{"id":1,"name":"Proctor A"}, ...]}`), so `DynamicForm.qml`'s `optionRows()` helper (which already handles "the result itself when it is an array, otherwise its first array-valued member" per `qml/DynamicForm.qml:236-244`) needs no change. - -- [ ] **Step 3: Build** - -Run: `cmake --build build/qml --target morph_forms_qml` (reuse the `build/qml` configure directory from the earlier `MORPH_BUILD_FORMS_QML=ON` work — if it no longer exists, reconfigure: `cmake -S . -B build/qml -G Ninja -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" -DMORPH_BUILD_FORMS_QML=ON -DCMAKE_BUILD_TYPE=Debug`) -Expected: builds cleanly. If it fails on `_handler{_bridge, &_gui}` member-initializer-list ordering (declaration order in the header must match: `_pool`, `_gui`, `_bridge`, `_handler` — already specified that way in Step 1), fix the header's member order to match and rebuild. - -- [ ] **Step 4: Manual smoke test** - -Run: `./build/qml/examples/forms/gui_qml/morph_forms_qml.exe` (add `$env:PATH` prefix with the Qt bin dir, or rely on the `windeployqt` post-build step from the earlier CMake work if already applied) and confirm the window opens with no crash and the forms render (they will not yet be reactive — the QML side is updated in Task 3). Close the window (or `Ctrl+C` the process) once confirmed. - -- [ ] **Step 5: Commit** - -```bash -git add examples/forms/gui_qml/FormsController.hpp examples/forms/gui_qml/FormsController.cpp -git commit -m "$(cat <<'EOF' -refactor: FormsController dispatches through Bridge/BridgeHandler - -Replaces hand-built wire::Envelope + RemoteServer plumbing with the -framework's real client API, matching how examples/bank's GUI talks to -its models. submit() is renamed submitIfValid() and now goes through -BridgeHandler::executeJson (added in the previous commit) instead of a -manually assembled envelope. -EOF -)" -``` - ---- - -### Task 3: Make `DynamicForm.qml` reactive (remove the submit button) - -**Files:** -- Modify: `examples/forms/gui_qml/qml/DynamicForm.qml` - -**Interfaces:** -- Consumes: `FormsController::submitIfValid(actionType, bodyJson)` (Task 2), existing `replyReceived`/`optionsReceived` signals (unchanged shape). -- Produces: no new public interface — this is a leaf QML component change. - -- [ ] **Step 1: Change `revalidate()` to call `submitIfValid` when the form is ready** - -In `examples/forms/gui_qml/qml/DynamicForm.qml`, find the `revalidate()` function (currently lines 180-227). At the end, where it currently sets: - -```qml - ready = ok - previewLine = ok ? "{" + parts.join(",") + "}" : "" -``` - -replace with: - -```qml - ready = ok - previewLine = ok ? "{" + parts.join(",") + "}" : "" - if (ready && form.controller) - form.controller.submitIfValid(form.actionType, form.previewLine) -``` - -- [ ] **Step 2: Remove the submit button from the reactive path** - -Find the `Button` element (currently lines 384-389): - -```qml - Button { - Layout.topMargin: 8 - text: "execute" - enabled: form.ready - onClicked: form.controller.submit(form.actionType, form.previewLine) - } -``` - -Replace it with a plain status label (keeps the layout stable, communicates the new reactive behavior instead of a dead control): - -```qml - Label { - Layout.topMargin: 8 - text: form.ready ? "✓ executes automatically as you type" : "fill the required (*) fields" - opacity: 0.6 - font.italic: true - } -``` - -- [ ] **Step 3: Build** - -Run: `cmake --build build/qml --target morph_forms_qml` -Expected: builds cleanly (QML changes do not require a C++ recompile, but re-running the build re-runs `qt_add_qml_module`'s QML resource packaging so the updated `.qml` file is picked up). - -- [ ] **Step 4: Manual smoke test — verify reactivity** - -Run: `./build/qml/examples/forms/gui_qml/morph_forms_qml.exe`. In the `ComputeDryDensity` form, type a value into `massDry`, then `volume`. Confirm: -- No "execute" button is present; the italic status label toggles from "fill the required fields" to "executes automatically as you type" once both fields have valid values. -- `resultText` updates automatically (no click needed) once both required fields are filled. -- Editing `volume` again after a result already appeared re-fires and updates `resultText` again without any click. - -Close the app once confirmed. - -- [ ] **Step 5: Commit** - -```bash -git add examples/forms/gui_qml/qml/DynamicForm.qml -git commit -m "$(cat <<'EOF' -feat: fire forms automatically on valid edit, remove submit button - -DynamicForm.qml now calls controller.submitIfValid() directly from -revalidate() whenever the assembled body passes client-side checks, -instead of waiting for a button click. Matches the live-recompute UX -the framework's fielded-action API models, without requiring a -compile-time set<&Action::field> hook per field. -EOF -)" -``` - ---- - -### Task 4: Qt Quick Test coverage for the reactive path - -**Files:** -- Modify: `examples/forms/gui_qml/tests/tst_main.cpp` (check its current structure first — it likely just registers the test directory; see Step 1) -- Create: `examples/forms/gui_qml/tests/tst_DynamicFormReactive.qml` - -**Interfaces:** -- Consumes: `DynamicForm.qml` (Task 3), a mock controller object exposing `submitIfValid`/`replyReceived`/`fetchOptions`/`optionsReceived` with the same signatures as `FormsController` (Qt Quick Test convention: a small QML mock, not the real C++ `FormsController`, so the test does not need a real `Bridge`/`LabModel` wired up). - -- [ ] **Step 1: Read the existing Quick Test setup** - -Run: `cat examples/forms/gui_qml/tests/tst_main.cpp` and list `examples/forms/gui_qml/tests/` to see what `.qml` test files already exist and what naming convention they use (Qt Quick Test auto-discovers `tst_*.qml` files in the directory passed via `-input` — confirmed by `gui_qml/CMakeLists.txt:42-45`: `add_test(... COMMAND morph_forms_qml_tests -input ${CMAKE_CURRENT_SOURCE_DIR}/tests)`). - -- [ ] **Step 2: Write the failing QML test** - -Create `examples/forms/gui_qml/tests/tst_DynamicFormReactive.qml` (adjust the mock controller's property/signal shape to match whatever pattern the existing test files in this directory already use, once Step 1's inspection is done — if this is the first QML test file in the suite, use the shape below): - -```qml -// SPDX-License-Identifier: Apache-2.0 -import QtQuick -import QtTest -import MorphForms - -TestCase { - id: testCase - name: "DynamicFormReactive" - - QtObject { - id: mockController - signal replyReceived(string actionType, bool ok, string payload) - signal optionsReceived(string optionsAction, bool ok, string payload) - - property int submitCount: 0 - property string lastActionType: "" - property string lastBodyJson: "" - - function submitIfValid(actionType, bodyJson) { - submitCount += 1 - lastActionType = actionType - lastBodyJson = bodyJson - replyReceived(actionType, true, JSON.stringify({sum: 42})) - } - - function fetchOptions(optionsAction) { - optionsReceived(optionsAction, true, "[]") - } - } - - property var testSchema: ({ - properties: { - a: { type: "integer", "x-order": 0 }, - b: { type: "integer", "x-order": 1 } - }, - required: ["a", "b"] - }) - - Component { - id: formComponent - DynamicForm { - actionType: "TestAction" - schema: testCase.testSchema - controller: mockController - } - } - - function test_fires_automatically_without_button() { - mockController.submitCount = 0 - var form = createTemporaryObject(formComponent, testCase) - verify(form !== null) - - // Locate the two TextField inputs in declaration order and type - // into them — DynamicForm has no button in the reactive path. - var fields = [] - function collect(item) { - for (var i = 0; i < item.children.length; ++i) { - var child = item.children[i] - if (child.hasOwnProperty("text") && child.toString().indexOf("TextField") !== -1) - fields.push(child) - collect(child) - } - } - collect(form) - compare(fields.length, 2) - - fields[0].text = "3" - fields[1].text = "4" - - compare(mockController.submitCount, 1) - compare(mockController.lastActionType, "TestAction") - compare(form.resultText !== "", true) - } - - function test_does_not_fire_when_incomplete() { - mockController.submitCount = 0 - var form = createTemporaryObject(formComponent, testCase) - verify(form !== null) - - var fields = [] - function collect(item) { - for (var i = 0; i < item.children.length; ++i) { - var child = item.children[i] - if (child.hasOwnProperty("text") && child.toString().indexOf("TextField") !== -1) - fields.push(child) - collect(child) - } - } - collect(form) - - fields[0].text = "3" - compare(mockController.submitCount, 0) - } -} -``` - -If the DOM-walk-based field lookup (`collect()`) proves brittle against `DynamicForm`'s actual visual tree (Qt Quick Test's `findChild`-by-objectName is more standard), add `objectName: "field_" + fieldColumn.modelData.name` to the `TextField` in `qml/DynamicForm.qml` (inside the `Repeater`'s delegate, on the `TextField id: entry` element, currently around line 341-351) and use `findChild(form, "field_a")`/`findChild(form, "field_b")` in the test instead — this is the more idiomatic Qt Quick Test approach and should be preferred; only fall back to the manual `collect()` walk if `findChild` is unavailable in the project's Qt version. Prefer adding the `objectName` and using `findChild`. - -Revised field-lookup approach (use this instead of `collect()`): - -In `qml/DynamicForm.qml`, on the `TextField` (currently starting at line 341): - -```qml - TextField { - id: entry - objectName: "field_" + fieldColumn.modelData.name - visible: !fieldColumn.modelData.isChoice && !fieldColumn.modelData.isDateTime - ... -``` - -And in the test, replace the `collect()`-based lookup with: - -```qml - function test_fires_automatically_without_button() { - mockController.submitCount = 0 - var form = createTemporaryObject(formComponent, testCase) - verify(form !== null) - - var fieldA = findChild(form, "field_a") - var fieldB = findChild(form, "field_b") - verify(fieldA !== null) - verify(fieldB !== null) - - fieldA.text = "3" - fieldB.text = "4" - - compare(mockController.submitCount, 1) - compare(mockController.lastActionType, "TestAction") - compare(form.resultText !== "", true) - } - - function test_does_not_fire_when_incomplete() { - mockController.submitCount = 0 - var form = createTemporaryObject(formComponent, testCase) - verify(form !== null) - - var fieldA = findChild(form, "field_a") - verify(fieldA !== null) - fieldA.text = "3" - compare(mockController.submitCount, 0) - } -``` - -- [ ] **Step 3: Add the `objectName` to `DynamicForm.qml`** - -Apply the `objectName: "field_" + fieldColumn.modelData.name` addition to the `TextField` shown in Step 2 above. - -- [ ] **Step 4: Run the test to verify it fails first (TDD check), then passes** - -Run: `cmake --build build/qml --target morph_forms_qml_tests` -Then: `ctest --test-dir build/qml -R forms_qml_logic --output-on-failure` - -Expected on first run (before Step 3's `objectName` addition is in place, if done out of order): `findChild` returns `null`, test fails on `verify(fieldA !== null)`. After Step 3 is applied: both tests PASS. - -- [ ] **Step 5: Run the full forms QML test suite for regressions** - -Run: `ctest --test-dir build/qml -R forms --output-on-failure` -Expected: `forms_qml_logic` and `forms_html_math` (if `node` is on `PATH`) both PASS — confirms the `objectName` addition and `revalidate()` change did not break any existing QML logic test. - -- [ ] **Step 6: Commit** - -```bash -git add examples/forms/gui_qml/qml/DynamicForm.qml examples/forms/gui_qml/tests/tst_DynamicFormReactive.qml -git commit -m "$(cat <<'EOF' -test: cover DynamicForm's auto-fire-on-valid behavior - -Adds a Qt Quick Test driving DynamicForm through simulated field edits -against a mock controller, asserting submitIfValid fires exactly once -the form becomes valid and not before — without any button click. -EOF -)" -``` - ---- - -### Task 5: Update the forms demo README - -**Files:** -- Modify: `examples/forms/README.md` - -**Interfaces:** None — documentation only. - -- [ ] **Step 1: Read the current README** - -Run: `cat examples/forms/README.md` to see its current description of the GUI's architecture (it likely still describes the `RemoteServer`/wire-envelope approach, matching the old `FormsController.hpp` file-header comment that said "wraps them in `morph::wire::Envelope`s and feeds them to an in-process `RemoteServer`"). - -- [ ] **Step 2: Update the architecture description** - -Find and replace any prose describing `FormsController` as wrapping actions in `wire::Envelope`s / talking to `RemoteServer` with a description matching the new design: `FormsController` owns a `Bridge` + `BridgeHandler` and dispatches via the generic `executeJson` path, exactly like `examples/bank`'s GUI controllers. Also add a short note that forms execute automatically once valid (no submit button), replacing any mention of a submit/execute button in the QML section. - -Since the exact current wording is only knowable by reading the file first (Step 1), the edit itself is: locate the paragraph(s) describing the GUI's transport and button-driven flow, and rewrite them to state: - -> The Qt Quick renderer (`gui_qml/`) dispatches actions through `morph::bridge::Bridge` + `BridgeHandler` — the same client API `examples/bank`'s GUI uses — via a generic, JSON-in/JSON-out `executeJson` path (see `BridgeHandler::executeJson` in `morph/bridge.hpp`). Forms have no submit button: each form fires automatically the moment its assembled body passes client-side validation, and re-fires on every subsequent edit. Because each keystroke can trigger a fresh dispatch with no coalescing, rapid edits may produce out-of-order replies for the same action (last-arrival wins on the displayed result) — acceptable for this demo's read-mostly compute actions, but worth knowing before reusing this pattern for actions with side effects. - -- [ ] **Step 3: Commit** - -```bash -git add examples/forms/README.md -git commit -m "$(cat <<'EOF' -docs: describe the Bridge/BridgeHandler-based reactive forms flow - -Updates the forms demo README to match FormsController's new transport -(Bridge/BridgeHandler via executeJson) and UX (auto-fire on valid edit, -no submit button), replacing the outdated RemoteServer/wire::Envelope -description. -EOF -)" -``` - ---- - -### Task 6: Full-suite regression pass and manual end-to-end verification - -**Files:** None modified — verification only. - -**Interfaces:** None — this task consumes everything built in Tasks 1-5. - -- [ ] **Step 1: Full native test suite** - -Run: `ctest --test-dir build/cl-debug --output-on-failure` -Expected: 100% pass (same as the baseline before this plan, plus the 3 new cases from Task 1). - -- [ ] **Step 2: Full Qt-enabled test suite (WebSocket backend, unrelated to this change but shares the `Bridge`/`bridge.hpp` header)** - -Run: `ctest --test-dir build/cl-qt-debug --output-on-failure` -Expected: same pass rate as the pre-existing baseline (365/366, with the one known pre-existing failure being an unrelated shell/em-dash encoding issue in a test filter name — see prior session notes). This confirms the `bridge.hpp` changes from Task 1 do not regress the Qt WebSocket backend, which also instantiates `BridgeHandler`. - -- [ ] **Step 3: Full QML forms test suite** - -Run: `ctest --test-dir build/qml -R forms --output-on-failure` -Expected: 100% pass, including the new `test_fires_automatically_without_button`/`test_does_not_fire_when_incomplete` cases from Task 4. - -- [ ] **Step 4: Manual end-to-end smoke test of the real app** - -Run: `./build/qml/examples/forms/gui_qml/morph_forms_qml.exe`. Walk through: -1. `ComputeDryDensity`: type `massDry` and `volume` values; confirm density computes and displays automatically, no click. -2. `RecordMeasurement`: confirm the `sampleId` combo box populates from `ListSamples` (via `fetchOptions`, unchanged path); fill `sampleId`, `measuredAt` (use the "now" button), and `density`; confirm it fires automatically once all required fields are set and `moisture`/`note` remain optional. -3. Trigger a validation error deliberately (e.g. if possible, clear a required field after a successful fire) and confirm the app does not crash and `resultText`/error display behaves sensibly. - -Close the app once all three are confirmed. - -- [ ] **Step 5: Report completion** - -No commit for this task (verification only) — if all steps pass, the branch is ready for the `superpowers:requesting-code-review` or `superpowers:finishing-a-development-branch` skill, at the user's discretion. - ---- - -### Self-Review Notes -- **Spec coverage:** Goals — "Bridge/BridgeHandler only" (Tasks 2-3), "fires automatically, no submit button" (Task 3), "zero new boilerplate per action" (Task 1's registration lives entirely inside `BRIDGE_REGISTER_ACTION_4`; `lab_model.hpp` is never touched in this plan), "`main.cpp` untouched" (no task modifies it) — all covered. Non-goals (`schemaJson` shape, `execute()`'s existing API, options-fetch reactivity, `--emit-html`) — none touched by any task. Testing section's three items (unit test for the registry, QML Quick Test, existing CTest wiring) map to Tasks 1 and 4 respectively. -- **Placeholder scan:** The one placeholder-like moment (Task 2 Step 2's first draft calling a nonexistent `lab::ActionTraits_SampleList_toJson`) is deliberately shown and then corrected within the same step with real code (`glz::write_json`), not left as a TBD — kept it in to document *why* the naive approach doesn't work (no `BRIDGE_REGISTER_ACTION` for `SampleList` itself), matching how the spec's own investigation-and-rejection of `set<>` was written up. -- **Type consistency:** `executeJson` signature (`std::string_view actionType, std::string_view bodyJson) -> Completion`) is identical across Task 1 Step 5 (definition) and Task 2 Step 2 (call site). `submitIfValid` signature matches between Task 2 Step 1 (`.hpp` declaration) and Step 2 (`.cpp` definition) and Task 3 Step 1 (QML call site) and Task 4's mock (test double). `ActionExecuteRegistry::instance()`/`execute()`/`registerAction()` names match between Task 1 Step 4 (class body) and Step 5/6 (call sites). +QML-facing API: + +- `Q_INVOKABLE void submitIfValid(QString actionType, QString bodyJson)` — + fully generic, no per-action branches. Calls + `_handler.executeJson(...)`; `.then()`/`.onError()` emit + `replyReceived(actionType, ok, payload)`. +- `fetchOptions` — backed by `_handler.execute(ListSamples{})`. This is the + one fixed, hand-known query (combo-box options); it does not grow with + the number of form actions. + +### `DynamicForm.qml` + +`revalidate()` computes `ready` and assembles the full JSON body +(`previewLine`) on every edit. When `ready` is `true` after an edit, it +calls `controller.submitIfValid(actionType, previewLine)` directly. +`previewLine` stays visible as a preview of what was last sent; +`resultText` updates live from the `onReplyReceived` handler. + +## Error handling + +- Unknown `actionType`: `executeJson` throws; `submitIfValid` catches and + emits `replyReceived` with `ok=false` (never lets an exception cross + Qt's slot-invocation boundary). +- Malformed JSON body: `ActionTraits::fromJson` throws + `ParseError`; same catch-and-report path. Defence-in-depth only — QML + gates on `revalidate()`'s required-field/regex checks before calling + `submitIfValid`. +- `Model::execute` throwing (e.g. `RecordMeasurement`'s `validate()` + guard): propagates through `Completion::onError` to + `replyReceived(actionType, false, message)`. + +## Known limitations + +- No coalescing: a burst of edits while a call is in flight queues + multiple concurrent `Bridge` calls; each resolves `replyReceived` + independently, last-writer-wins on `resultText`. If this proves janky, a + ~150 ms debounce timer in `DynamicForm.qml` is a self-contained + follow-up. +- `BridgeHandler`'s compile-time fielded-draft API + (`set<&Action::field>`/`subscribe`) is not usable generically: + glaze plain-aggregate reflection exposes only field names, not + compile-time member pointers. The generic path goes through + `executeJson` instead. +- The options combo-box fetch is not reactive; it runs once per form from + `Component.onCompleted`. +- `examples/forms/main.cpp` (schema dump / `--emit-html` / REPL) has no GUI + and does not use `FormsController`; the static `--emit-html` page is pure + JS with its own test (`test_html_math.mjs`). + +## Testing + +- `tests/test_bridge_execute_json.cpp` (Catch2): registers a test action + via `BRIDGE_REGISTER_ACTION`, asserts `executeJson` resolves with the + JSON-encoded result, and that a malformed body surfaces a parse error + through `onError`. +- `examples/forms/gui_qml/tests/tst_main.cpp` (Qt Quick Test): drives + `DynamicForm` through simulated field edits and asserts `resultText` + updates without any button click. Runs offscreen; the test target + deploys the Qt runtime + offscreen plugin.