morph::views::viewSchemaJson<V>() composes a registered query action's
result rows with an optional row-opener action and row/collection action
buttons into a view-schema document a renderer builds a list/table or
master-detail screen from. Where morph::forms::schemaJson<A>()
describes one action, a view describes a set of related actions — a query,
an edit, a delete — and how to choreograph their already-generated forms.
Terminology note. "Screen" here is the informal, UI sense — whatever a view renders into. It is a different, not-yet-connected concept from the app-shell layer's formal
Screenfamily (FormScreen/WizardScreen/kind, workflows_navigation.md): a view is not yet one of that layer's declarable screen kinds (see Non-goals).
- The gap this closes
- A new, separate top-level document
ActionDescriptoranddescribeAction<Action>()- Declaring a view in C++
- Column derivation
- The JSON key vocabulary
ViewTraits<V>/BRIDGE_REGISTER_VIEW/ViewRegistry- Dispatch: the screen is still just action calls
- The Qt/QML reference renderer
- API reference
- Design decisions
- Failure modes
- Limitations
- Non-goals
- Cross-references
The forms layer is one action → one flat form
(forms.md). Nothing says "run query action ListSamples, show
its rows as a table, let a row open the edit form for action EditSample,
and let a button run DeleteSample" — every create/read/update/delete
(CRUD) screen had to be hand-wired,
even though all three actions are already registered
(registry.md) and each already generates its own form.
Choice (choice.md) already proves the smaller version of this
pattern — a query action, executed with an empty body, serves rows a
renderer maps to {value, label}. A view generalises "a query action serves
rows" from a combo box to a full table with per-row actions.
schemaJson<A>() describes one action; viewSchemaJson<V>() describes a
view — a separate JSON document, never merged into any action schema.
An action schema a Tier-1 (per-action
x-* schema) renderer consumes is byte-for-byte unchanged by this
layer; a renderer that knows nothing about views still renders every
referenced action as a plain form. The view document only references action
type-ids (the same string ids ActionTraits<A>::typeId() and
x-optionsAction already use).
Every action a view references — its query, its row-opener, each button —
is a morph::views::ActionDescriptor, always built by
describeAction<Action>(...), never constructed by hand:
struct BindEntry {
std::string_view actionField; // wire field on the target action
std::string_view rowField; // wire field on the row to read the prefill from
};
struct ActionDescriptor {
std::string_view actionTypeId; // resolved from ActionTraits<Action>::typeId()
std::string_view label{}; // "" = use the action type id as-is
ActionScope scope{ActionScope::Row}; // Row or Collection
std::span<const BindEntry> bind{}; // target-field <- row-field prefill map
bool confirm{false};
};
template <typename Action>
consteval ActionDescriptor describeAction(std::string_view label = {},
ActionScope scope = ActionScope::Row,
std::span<const BindEntry> bind = {},
bool confirm = false);describeAction<Action>() requires ActionTraits<Action> to already be a
complete specialisation (i.e. BRIDGE_REGISTER_ACTION for Action must
already have run earlier in the translation unit) — a typo'd or unregistered
action is a compile error here, not the runtime-only failure
Choice's unchecked OptionsAction FixedString NTTP allows (see
choice.md, "Limitations"). BindEntry{actionField, rowField}
maps one target-action wire field to one row wire field; bind is a pure
client-side prefill — the wire payload the action eventually fires is the
ordinary action body. bind can name more than one field, but see
"Limitations" below before binding every required field of a row-opener
action: combined with a no-submit-button/auto-fire-on-ready renderer (the
Qt/QML reference renderer), that fires the action the instant the row opens.
struct SamplesView {
using kind = morph::views::CollectionView; // or MasterDetailView
using query = ListSamples; // registered query action
static constexpr std::string_view title = "Samples"; // optional; default: v-query's typeId
static constexpr std::string_view rowKey = "id"; // optional; default "id"
static constexpr std::array<morph::views::BindEntry, 1> kEditBind{
morph::views::BindEntry{.actionField = "id", .rowField = "id"},
};
static constexpr auto rowAction = // optional
morph::views::describeAction<EditSample>({}, morph::views::ActionScope::Row, kEditBind);
static constexpr std::array<morph::views::BindEntry, 1> kDeleteBind{
morph::views::BindEntry{.actionField = "id", .rowField = "id"},
};
static constexpr std::array<morph::views::ActionDescriptor, 2> actions{ // optional
morph::views::describeAction<DeleteSample>("Delete", morph::views::ActionScope::Row, kDeleteBind, true),
morph::views::describeAction<CreateSample>("New", morph::views::ActionScope::Collection),
};
// optional: static constexpr std::array<morph::views::ColumnOverride, N> columns;
};
using lab::SamplesView;
BRIDGE_REGISTER_VIEW(SamplesView, "SamplesView")kind and query are the only required members. title, rowKey,
columns, rowAction, and actions are each detected structurally (via a
requires-expression, not inheritance or a marker base) and are individually
optional — absence falls back to the defaults below. This exact descriptor is
examples/forms/lab_schemas.hpp's SamplesView, composed from
examples/forms/lab_model.hpp's ListSamples/EditSample/DeleteSample/
CreateSample.
viewSchemaJson<V>() finds the query action's row element type — the
result itself when it is a std::vector<Row>, otherwise its first
std::vector<Row>-typed member in declaration order (the same two shapes
Choice's renderer-side optionRows reads) — and derives one column per
Row field by calling morph::forms::schemaJson<Row>() directly: Row
need not be a registered action, only a reflectable, default-constructible
aggregate, which is exactly what schemaJson<A>() already requires of any
type it is instantiated on. Each derived column carries:
field/label— the wire key;labeldefaults tofield.- Declaration order — the row schema's
x-order, ascending. x-decimalPlaces/ExtUnits— copied off the row schema's property node for aQuantityfield, so a column formats a value exactly as that field's own form would. AQuantitytype used only once onRowcarriesExtUnitsdirectly on its property node (glaze inlines a single-use struct schema in place); glaze only promotes the nested schema to a$defsentry (referenced back via the property's$ref) when the sameQuantitytype occurs on more than one property ofRow(see forms.md's renderer conformance kit, whoseCFSharedDefFieldsfixture covers exactly this case) — column derivation reads whichever shapeschemaJson<Row>()actually produced, trying the inlined property first and falling back to the$ref/$defsindirection.
V::columns (a static constexpr std::array<ColumnOverride, N>) is the
declare-to-override escape hatch: supplying it emits exactly the declared
entries, in declared order — reordering, relabeling, hiding
(ColumnOverride::hidden), or subsetting the derived set. A hidden column is
still emitted (with "v-hidden": true) — a renderer keeps the field in its
row model without displaying it. A declared field the row type does not
have is emitted as a bare {field, label} column with no x-decimalPlaces /
ExtUnits and no crash (schema generation never throws, matching
forms.md's failure-mode discipline).
| Key | Where | JSON type | Meaning |
|---|---|---|---|
v-kind |
top-level | string | "collection" or "master-detail". The one required discriminator beyond v-query. |
v-title |
top-level | string | Screen title. Defaults to v-query's type id. |
v-query |
top-level | string | Registered query action's type id; executed with an empty body. |
v-rowKey |
top-level | string | Wire field uniquely identifying a row. Defaults to "id". |
v-columns |
top-level | array | Ordered column descriptors: {field, label, "v-hidden"?, "x-decimalPlaces"?, ExtUnits?}. Derived from the row type unless V::columns overrides it. |
v-rowAction |
top-level | object | {action, bind?} — the action a row activation opens. Omitted when V declares no rowAction. Carries only action/bind, never label/scope/confirm. |
v-actions |
top-level | array | {action, label, scope, bind?, confirm?} per entry — buttons that run an action. scope is "row" or "collection"; confirm is omitted when false; bind is omitted when empty. Omitted entirely when V declares no actions. |
A concrete viewSchemaJson<SamplesView>() output, for the SamplesView
declared above (row type carrying id/name):
{
"v-kind": "collection",
"v-title": "Samples",
"v-query": "ListSamples",
"v-rowKey": "id",
"v-columns": [
{ "field": "id", "label": "Id" },
{ "field": "name", "label": "Name" }
],
"v-rowAction": {
"action": "EditSample",
"bind": { "id": "id" }
},
"v-actions": [
{ "action": "DeleteSample", "label": "Delete", "scope": "row", "bind": { "id": "id" }, "confirm": true },
{ "action": "CreateSample", "label": "New", "scope": "collection" }
]
}bind, wherever it appears (v-rowAction.bind or a v-actions entry's
bind), is a plain JSON object mapping each target-action wire field to
a row wire field — {"id": "id"}, not an array of {actionField, rowField}
entries. This is ActionDescriptor::bind's std::span<const BindEntry>
serialised key-by-key (buildActionNode), and it is what a renderer must
iterate with a for...in over its own keys, not by numeric index.
All keys are additive: a renderer that does not recognise v-* never
builds a screen from this document, but every action it references still
renders as an ordinary standalone form from its own schemaJson<A>() — the
view document changes nothing about that action's own schema (verified: no
v-* key is ever present in an action schema).
BRIDGE_REGISTER_VIEW(V, NAME) specialises ViewTraits<V> (a typeId()
accessor, parallel to ActionTraits<A>) and registers V's
viewSchemaJson<V>() provider with the process-level ViewRegistry at
static-init time — the same static-initialiser pattern
BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION use
(registry.md), including its token-pasting constraint
(V must be an unqualified name in scope at the call site). Unlike
BRIDGE_REGISTER_ACTION, this macro needs only <morph/core/registry.hpp> —
a view registers no executor, only a schema provider, so there is no
<morph/core/bridge.hpp> dependency and no registry.hpp → bridge.hpp
include-cycle concern. ViewRegistry::instance().schemaJson(viewId) looks up
a registered view's document by string id (throws std::runtime_error for an
unknown id, mirroring ActionExecuteRegistry::execute); viewIds() lists
every registered id, so a controller can enumerate views the way it already
enumerates action schemas.
A screen performs three ordinary dispatches, each already fully specified elsewhere:
- Populate — execute
v-querywith an empty body via the normal execute path (bridge.md); read rows from the result exactly asChoicedoes. - Edit — activating a row builds the
v-rowActionaction's form (forms.md) withbind-listed fields prefilled, then fires it through the sameexecuteJsonpath as any form. - Delete/other actions — a
v-actionsentry fires its bound action, guarded byconfirmwhen set.
After an edit/delete/create resolves, the renderer re-runs step 1 to refresh — there is no push channel for collection changes (see Non-goals).
src/qt/forms/qml/CollectionView.qml is the reference implementation,
shipped as part of the MorphForms QML module (alongside DynamicForm.qml,
which it reuses unmodified as the row editor — see
forms.md, "Shipped Qt/QML reference renderer", for why the
renderer lives there and not in examples/forms/gui_qml, the demo
consumer). It parses FormsController::viewsJson (a {viewType: viewSchema} object, mirroring schemasJson), renders v-columns as a
table, and instantiates DynamicForm as a modal Dialog for v-kind: "collection" or a live side pane for "master-detail". Populate/edit/
delete/create are issued purely through the existing
controller.submitIfValid(actionType, bodyJson) / replyReceived surface —
no new controller method was needed.
Row-open prefill first calls DynamicForm.resetFields(), then locates the
actual field_<name> TextField instance DynamicForm.qml draws for each bound
plain scalar field and sets its text — the same path a user's own typing
takes (via that control's own onTextChanged), rather than reaching into
DynamicForm's internal fieldValues state directly, since DynamicForm.qml
has no external "set a field's displayed value" API (every other control
already owns writing its own text/selection from the user's input). See
"Limitations" for what this does not reach.
The reset is not optional. modalForm/detailForm are single instances
created once inside their Dialog and reused for every row, and prefill only
writes the fields v-rowAction names in bind. Without clearing first,
everything else kept whatever the user left there while editing the previous
row — and because the renderer auto-fires as soon as the form is ready (see
Limitations), merely opening the next row fired the row action with that
row's key and the previous row's field values: a silent write of data the user
neither entered nor saw. resetFields() runs with auto-submit suppressed (see
DynamicForm's programmaticEdit counter), so repopulating a form never fires
an action by itself.
examples/forms/lab_schemas.hpp's SamplesView (composed from
ListSamples/EditSample/DeleteSample/CreateSample in
examples/forms/lab_model.hpp) is the worked example.
examples/forms/gui_qml/qml/Main.qml renders every registered view alongside
the per-action forms, excluding from the standalone-forms list any action a
view already owns (its query, row-opener, or a v-actions target), so
nothing renders twice.
CollectionView.slotRegistry (null by default) is handed to both editor
DynamicForms, so field slots and form chrome registered once apply inside
the editor too. The view's own chrome goes through the same
SlotRegistry.byChrome registry (forms.md, "Chrome slots"),
each role replacing the built-in, which is then not drawn; members are
assigned only where the chrome declares them:
| Role | Replaces | Members |
|---|---|---|
collectionHeader |
the title and the column-header row with its collection actions | title, columns (visible v-columns), actions (collection scope), fire(action) |
collectionRow |
one row's cells, Open button and row actions | row, columns, cells (formatted texts), rowKey, actions (row scope), canOpen, open(), fire(action) |
confirmDialog |
the "Are you sure?" dialog | message, action, row, accept(), reject() — loaded only while an action waits |
editorDialog |
the collection kind's modal editor | title, open, close(); must declare contentItem, into which the view reparents the editor form |
fire() and accept() make exactly the controller calls the built-in buttons
make (confirmation included), and open() runs the same prefill. The root is
a Frame, whose background/padding a host sets on the instance.
src/qt/forms/tests/tst_ViewChrome.qml pins every role and a fully chromed
screen, editor open, with no visible built-in Label or Button.
| Signature | Returns |
|---|---|
template <typename V> const std::string& viewSchemaJson() |
The view-schema JSON. Cached per type and returned by reference, exactly like schemaJson<A>() — the cache is built once per type per process, never mutated afterwards and lives until the process exits, so the reference stays valid for as long as any caller could hold it; a caller wanting its own mutable copy asks for one (std::string mine = viewSchemaJson<V>();). Never throws — internal DOM (parsed JSON document tree) failure yields an empty string, matching schemaJson<A>(). |
| Symbol | Kind | Notes |
|---|---|---|
ActionScope |
enum class | {Row, Collection}. |
BindEntry{actionField, rowField} |
struct | One prefill mapping. |
ActionDescriptor{actionTypeId, label, scope, bind, confirm} |
struct | Always built via describeAction. |
describeAction<Action>(label, scope, bind, confirm) |
consteval function template |
Resolves actionTypeId from ActionTraits<Action>::typeId(). |
ColumnOverride{field, label, hidden} |
struct | One V::columns entry. |
CollectionView / MasterDetailView |
empty tag structs | V::kind. |
| Symbol | Kind | Notes |
|---|---|---|
ViewTraits<V> |
class template | Customisation point. static constexpr std::string_view typeId(). Specialise via BRIDGE_REGISTER_VIEW. |
BRIDGE_REGISTER_VIEW(V, NAME) |
macro | Specialises ViewTraits<V> and registers V with ViewRegistry at static-init time. |
ViewRegistry::registerView<V>(viewId) |
method template | Registers V's schema provider — a std::function<const std::string&()>, so the registry does not reintroduce the per-call copy viewSchemaJson<V>() avoids (last-write-wins on a repeated viewId). |
ViewRegistry::schemaJson(viewId) const |
method | Returns the cached viewSchemaJson<V>() for viewId, by const std::string& — the provider's reference is forwarded through, so enumerating every registered view copies no schema text; throws std::runtime_error if unknown. |
ViewRegistry::viewIds() const |
method | Every registered view id. |
ViewRegistry::instance() |
static method | Process-level singleton. |
| Decision | Choice | Why |
|---|---|---|
| Action references | ActionDescriptor built by describeAction<Action>(), not a bare ActionList<...> type-list |
A homogeneous struct built by a consteval factory keeps V::actions a plain std::array (needed for iteration) while still resolving actionTypeId from the real, registered ActionTraits<Action>, compile-time-checked rather than a hand-typed string; it also carries per-action label/scope/bind/confirm, which a bare type-list cannot. |
| Column source of truth | Reuse morph::forms::schemaJson<Row>(), not a second reflection pass |
One source for x-order/x-decimalPlaces/ExtUnits — a column formats a value exactly as that field's own form does, by construction, never by a second, potentially drifting implementation. |
| View registration | Own ViewRegistry, no bridge.hpp dependency |
A view has no dispatch path (no executor to register), unlike an action — so, unlike BRIDGE_REGISTER_ACTION, BRIDGE_REGISTER_VIEW needs only registry.hpp's ActionTraits. |
| Master-detail vs. collection | Same document, v-kind only |
No new keys — a rendering choice, not a schema difference. |
| Hidden columns | Still emitted, v-hidden: true |
A renderer keeps the field in its row model (e.g. for a later feature) without displaying it — "hidden" is presentational, not an omission. |
bind's wire shape |
A JSON object ({actionField: rowField}), not an array of entries |
Matches ActionDescriptor::bind's natural key → value mapping and keeps lookups by field name O(1) for a renderer; buildActionNode serialises std::span<const BindEntry> this way. |
Schema generation never throws: viewSchemaJson<V>() yields the same
best-effort/empty-string fallback discipline schemaJson<A>() documents (see
forms.md, "Total schema failure yields an empty string"). A
declared ColumnOverride::field or BindEntry naming a wire key the row/
target-action type does not have is silently accepted (a bare column, or a
bind entry the target action ignores on decode) rather than rejected.
ViewRegistry::schemaJson/ActionExecuteRegistry::execute-style lookups
throw std::runtime_error only for a wholly unregistered view/action id, not
for a malformed reference within an otherwise-valid one.
A row id is carried exactly, whatever its magnitude — in the rendered cell, in
the objectName that identifies a row, and in the body of any action bound to
that row. CollectionView parses its query reply with JsonExact.parse
(src/qt/forms/qml/JsonExact.js), which keeps an integer literal a double
cannot represent as its exact digits, and bindBodyJson emits those digits
verbatim rather than re-serialising a rounded double.
The failure this prevents is silent and destructive. JavaScript numbers are
IEEE-754 doubles and round to even above 2^53, so neighbouring ids collapse onto
one value: two rows become indistinguishable, and a confirmed Delete on one
builds a body naming the other. Nothing downstream can notice — the server side
is exact throughout, so such an action decodes cleanly, validates, and deletes
precisely the wrong row.
The same guarantee for Choice option ids is in
choice.md.
Every limitation choice.md documents for its "a query action serves rows"
pattern applies unchanged here, since a view's populate step is exactly that
pattern:
- Membership/staleness not enforced. A row's bound field values may be stale by the time a prefilled action fires; the handler must re-check.
- Unchecked wire vocabulary.
bind's field names are wire keys, resolved at runtime by the renderer, not checked against the target action's schema at declaration time (thoughdescribeAction<Action>()does compile-time-check thatActionitself is a real, registered action — a stronger guarantee thanChoice'sOptionsActionNTTP has). - The query action's result type must be default-constructible when it is
not itself a
std::vector<...>—viewSchemaJson<V>()builds a probe instance purely to find the row-bearing member via reflection, the same constraint forms.md already places on every action type formergeSchemaExtras. - No i18n. Like every other cached-per-type schema in this layer, labels
(
title, column labels, action labels) are fixed at first call. The Qt/QML reference renderer'sCollectionView.qmldoes not (yet) accept anI18nCatalog/displayLocalethe wayDynamicForm.qmldoes. - Binding every required field of a row-opener, on a no-submit-button
renderer, fires the action the instant the row opens. A view schema
itself has no notion of "submit" — a row-opener form fires exactly when
the renderer decides it is ready, and the Qt/QML reference renderer
(
DynamicForm.qml) decides that the moment every required field is engaged, with no submit button to hold it back. Ifbindprefills every required field (not just the row key), the freshly-opened editor is already "ready" and auto-fires before the user reviews or changes anything.examples/forms/lab_schemas.hpp'sSamplesViewavoids this by binding onlyid—namestarts blank, so the edit fires only once the user actually types a new name. A renderer with an explicit submit step would not have this hazard; a no-submit-button renderer's authors must bind deliberately. CollectionView.qml's row-open prefill only reaches a plain scalar field. It locates the bound field by DynamicForm's `objectName: "field_"- name
convention (the defaultTextFielda plain integer/string property renders as) and sets itstext. Abindentry targeting a field DynamicForm renders as aChoicecombo box, aTimestamp/date-time picker, a slider, a multiline text area or a radio group is not currently prefilled — the control is simply left at its own default, exactly as an unbound field would be (no crash, no partial state). ASlotRegistry-overridden scalar field is prefilled only when its slot declares the optionalfieldTextmember ([forms.md](forms.md#theming--component-override-registry)): the hidden defaultTextFieldstill takes the text, andfieldTextcarries the resulting value to the slot. Every worked example (SamplesView) only ever binds the integer row key, which always renders as a plainTextField`, so this gap does not affect the shipped demo.
- name
- Not an app framework. A view is one screen composed of existing
action-forms. Multi-screen navigation, menus, and cross-screen flow are a
separate concern, implemented in
workflows_navigation.md — a view is not itself
one of that layer's screen kinds yet (
ViewScreen/kind: "view"is reserved but not declared there). - No new dispatch path or wire change. Populate/edit/delete/create are ordinary action executes over the existing path (bridge.md); the view document is metadata a renderer consumes, never a payload.
- No server-side "query language."
v-querynames a registered action that returns whatever rows it returns; paging/filtering, if wanted, is a field on that action, not a view-schema concept. - No live/push list updates. A list refreshes by re-running its query after a mutating action resolves — no subscription channel.
- No nested/joined views. A view references flat action row shapes, matching the flat-actions-only scope of schema generation (forms.md).
- forms.md —
schemaJson<A>(), thex-order/ExtUnits/x-decimalPlacesreused verbatim for derived columns, the shipped Qt/QML renderer toolkitCollectionView.qmlships alongside, and the flat-actions-only scope inherited here. - choice.md — the "a query action serves rows" pattern generalised from a combo box to a table, including the empty-body query contract and the stale-value caveat.
- ../core/bridge.md — the execute /
executeJsonpath populate/edit/delete/create dispatch uses unchanged. - ../core/registry.md —
ActionTraits::typeId()(the idsdescribeActionresolves), and theBRIDGE_REGISTER_ACTIONpatternBRIDGE_REGISTER_VIEWmirrors. - workflows_navigation.md — the wizard/app-shell
layer this view's Non-goals defers menus and cross-screen flow to; that
layer's
app-*document reserves but does not yet declare akind: "view"screen referencing a view like this one.