morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
views.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4
5/// @file forms/views.hpp
6/// @brief View-schema generation: composes a registered query action's
7/// result rows with an optional row-opener action and row/collection
8/// action buttons into a list/table or master-detail **screen**
9/// descriptor.
10///
11/// Where `morph::forms::schemaJson<A>()` describes *one action*, this header
12/// describes a *set* of related actions — a query, an edit, a delete — as a
13/// single, additional JSON document, `morph::views::viewSchemaJson<V>()`,
14/// emitted alongside (never merged into) the action schemas. See
15/// docs/spec/forms/views.md.
16
17#include <algorithm>
18#include <cstddef>
19#include <cstdint>
20#include <functional>
21#include <glaze/glaze.hpp>
22#include <span>
23#include <stdexcept>
24#include <string>
25#include <string_view>
26#include <type_traits>
27#include <unordered_map>
28#include <utility>
29#include <vector>
30
31#include "../core/registry.hpp"
32#include "forms.hpp"
33
34namespace morph::views {
35
36/// @brief Where a view action button appears: opened per row, or once for
37/// the whole collection.
38enum class ActionScope : std::uint8_t { Row, Collection };
39
40/// @brief Maps one target-action wire field to a row wire field, so a row's
41/// current value prefills the action's draft before it fires.
42struct BindEntry {
43 /// @brief Wire field name on the target action.
44 std::string_view actionField;
45 /// @brief Wire field name on the query action's row type.
46 std::string_view rowField;
47};
48
49/// @brief One action a view screen can fire — the row opener (`v-rowAction`)
50/// or an entry of `v-actions`.
51///
52/// Always built by `describeAction<Action>(...)`, never constructed by hand,
53/// so `actionTypeId` always reflects a real, registered
54/// `ActionTraits<Action>::typeId()` rather than a hand-typed, unchecked
55/// string (contrast `morph::forms::Choice`'s `OptionsAction` NTTP, which
56/// *is* an unchecked string — see docs/spec/forms/choice.md, "Limitations").
58 /// @brief Registered type id of the target action.
59 std::string_view actionTypeId;
60 /// @brief Button label. Empty means "use the action type id as-is".
61 std::string_view label{};
62 /// @brief Row button vs. collection-wide button.
63 ActionScope scope{ActionScope::Row};
64 /// @brief Field-name mapping applied to prefill the target action's
65 /// draft from the activating row. Empty for an action that needs
66 /// no row context (e.g. a collection-scope "create").
67 std::span<const BindEntry> bind{};
68 /// @brief Whether a renderer must confirm before firing this action.
69 bool confirm{false};
70};
71
72/// @brief Builds an `ActionDescriptor` for @p Action, resolving its wire type
73/// id from the real, registered `ActionTraits<Action>` rather than a
74/// hand-typed string.
75/// @tparam Action Registered action type — `BRIDGE_REGISTER_ACTION` for
76/// @p Action must already have run earlier in this
77/// translation unit, or `ActionTraits<Action>` is incomplete
78/// and this fails to compile.
79/// @param label Button label. Defaults to empty ("use the action type id").
80/// @param scope Row button vs. collection-wide button. Defaults to `Row`.
81/// @param bind Field-name mapping prefilling the action's draft from the
82/// activating row. Defaults to empty (no prefill).
83/// @param confirm Whether a renderer must confirm before firing. Defaults to
84/// `false`.
85/// @return The populated descriptor.
86template <typename Action>
87[[nodiscard]] consteval ActionDescriptor describeAction(std::string_view label = {},
88 ActionScope scope = ActionScope::Row,
89 std::span<const BindEntry> bind = {}, bool confirm = false) {
90 return ActionDescriptor{
92 .label = label,
93 .scope = scope,
94 .bind = bind,
95 .confirm = confirm,
96 };
97}
98
99/// @brief Declare-to-override entry for one derived column of a collection
100/// view.
101///
102/// Supplying a `static constexpr std::array<ColumnOverride, N> columns` on a
103/// view descriptor reorders, relabels, hides, or subsets the columns
104/// `viewSchemaJson` would otherwise derive from the query's row type; a
105/// `field` naming a wire key the row type does not have is emitted as a bare
106/// column (schema generation never throws — see docs/spec/forms/forms.md).
108 /// @brief Wire field name on the query action's row type.
109 std::string_view field;
110 /// @brief Column header. Empty means "use `field` as-is".
111 std::string_view label{};
112 /// @brief Column present in the row model but not displayed.
113 bool hidden{false};
114};
115
116/// @brief Tag type: `V::kind` selects a plain list/table screen.
118
119/// @brief Tag type: `V::kind` selects a list + inline/side editor screen.
120/// Introduces no JSON keys beyond `CollectionView` — purely a
121/// rendering choice a conformant renderer may act on.
123
124namespace detail {
125
126/// @brief `false` for every type; specialised `true` for `std::vector<...>`.
127template <typename T>
128inline constexpr bool isStdVector = false;
129
130/// @brief Specialisation: `true` for any `std::vector<T, Alloc>`.
131template <typename T, typename Alloc>
132inline constexpr bool isStdVector<std::vector<T, Alloc>> = true;
133
134/// @brief `"collection"` / `"master-detail"` for the two kind tags.
135template <typename Kind>
136struct ViewKindNameImpl;
137
138template <>
139struct ViewKindNameImpl<CollectionView> {
140 static constexpr std::string_view value = "collection";
141};
142
143template <>
144struct ViewKindNameImpl<MasterDetailView> {
145 static constexpr std::string_view value = "master-detail";
146};
147
148/// @brief The `v-kind` wire string for kind tag @p Kind.
149template <typename Kind>
150inline constexpr std::string_view viewKindName = ViewKindNameImpl<Kind>::value;
151
152/// @brief Concept: `V` declares an optional `static constexpr` title.
153template <typename V>
154concept HasViewTitle = requires { V::title; };
155
156/// @brief Concept: `V` declares an optional `static constexpr` row key.
157template <typename V>
158concept HasRowKey = requires { V::rowKey; };
159
160/// @brief Concept: `V` declares an optional column-override list.
161template <typename V>
162concept HasColumnOverrides = requires {
163 std::begin(V::columns);
164 std::end(V::columns);
165};
166
167/// @brief Concept: `V` declares an optional row-opener action descriptor.
168template <typename V>
169concept HasRowActionDescriptor = requires { V::rowAction; };
170
171/// @brief Concept: `V` declares an optional action-button list.
172template <typename V>
173concept HasViewActions = requires {
174 std::begin(V::actions);
175 std::end(V::actions);
176};
177
178/// @brief The node a property's schema resolves to: its `$defs` entry when it
179/// is a `$ref`, else the property itself. A nullable property, which
180/// glaze writes for `std::optional<T>` as `anyOf` of `T` and `null`,
181/// resolves through its `T` alternative.
182/// @param rowDom The row type's schema DOM.
183/// @param prop A property node.
184/// @return The resolved node; the unresolved one when the reference does not resolve.
185[[nodiscard]] inline glz::generic_u64 const* resolveSchemaRef(glz::generic_u64 const& rowDom,
186 glz::generic_u64 const& prop) {
187 using ::morph::forms::detail::findMember;
188 auto const* node = &prop;
189 if (auto const* const anyOf = findMember(prop, "anyOf"); anyOf != nullptr && anyOf->is_array()) {
190 for (auto const& alternative : anyOf->get_array()) {
191 auto const* const type = findMember(alternative, "type");
192 if (type == nullptr || !type->is_string() || type->get_string() != "null") {
193 node = &alternative;
194 break;
195 }
196 }
197 }
198 auto const* const ref = findMember(*node, "$ref");
199 if (ref == nullptr || !ref->is_string()) {
200 return node;
201 }
202 auto const& refText = ref->get_string();
203 auto const defName = refText.substr(refText.find_last_of('/') + 1);
204 auto const* const defs = findMember(rowDom, "$defs");
205 auto const* const def = (defs == nullptr) ? nullptr : findMember(*defs, defName);
206 return def == nullptr ? node : def;
207}
208
209/// @brief A property's JSON-Schema `type`, ignoring a `null` alternative.
210/// @param node A resolved property node.
211/// @return The type name, or empty when the node declares none.
212[[nodiscard]] inline std::string schemaType(glz::generic_u64 const& node) {
213 auto const* const type = ::morph::forms::detail::findMember(node, "type");
214 if (type == nullptr) {
215 return {};
216 }
217 if (type->is_string()) {
218 return type->get_string();
219 }
220 if (type->is_array()) {
221 for (auto const& alternative : type->get_array()) {
222 if (alternative.is_string() && alternative.get_string() != "null") {
223 return alternative.get_string();
224 }
225 }
226 }
227 return {};
228}
229
230/// @brief The string a schema node holds under @p key, or empty.
231/// @param node A schema node.
232/// @param key The member's name.
233/// @return The member's text, or empty when it is absent or not a string.
234[[nodiscard]] inline std::string schemaText(glz::generic_u64 const& node, std::string_view key) {
235 auto const* const member = ::morph::forms::detail::findMember(node, key);
236 return (member != nullptr && member->is_string()) ? member->get_string() : std::string{};
237}
238
239/// @brief The table column kind of a row property (spec 7 §4), by the names
240/// `morph::table::ColumnKind` reads: `integer`, `decimal`, `quantity`,
241/// `number`, `text`, `date`, `dateTime`, `bool`, `custom`.
242///
243/// `x-comparator` makes a column `custom`; an exact value (`num`/`den`) is
244/// `quantity` when it carries units and `decimal` otherwise; an integer with
245/// `x-widget` `epochDays` or `epochSeconds` is a `date` or `dateTime`; a
246/// string with `format` `date` or `date-time` likewise; a boolean is `bool`;
247/// any other number is `number`; anything else is `text`.
248/// @param rowDom The row type's schema DOM.
249/// @param prop The property node.
250/// @return The kind's name.
251[[nodiscard]] inline std::string_view inferColumnKind(glz::generic_u64 const& rowDom, glz::generic_u64 const& prop) {
252 using ::morph::forms::detail::findMember;
253 if (findMember(prop, "x-comparator") != nullptr) {
254 return "custom";
255 }
256 auto const& node = *resolveSchemaRef(rowDom, prop);
257 auto const* const properties = findMember(node, "properties");
258 if (properties != nullptr && findMember(*properties, "num") != nullptr &&
259 findMember(*properties, "den") != nullptr) {
260 bool const units = findMember(prop, "ExtUnits") != nullptr || findMember(node, "ExtUnits") != nullptr;
261 return units ? "quantity" : "decimal";
262 }
263 auto const type = schemaType(node);
264 auto const widget = schemaText(prop, "x-widget");
265 auto const format = schemaText(node, "format");
266 if (type == "integer") {
267 if (widget == "epochDays") {
268 return "date";
269 }
270 return widget == "epochSeconds" ? "dateTime" : "integer";
271 }
272 if (type == "number") {
273 return "number";
274 }
275 if (type == "boolean") {
276 return "bool";
277 }
278 if (format == "date") {
279 return "date";
280 }
281 if (format == "date-time") {
282 return "dateTime";
283 }
284 return "text";
285}
286
287/// @brief Builds one `v-columns` entry, copying `x-decimalPlaces` /
288/// `ExtUnits` off @p rowDom's property node (and, via its `$ref`, the
289/// resolved `$def`) when @p field names a `Quantity` property.
290/// @param rowDom The row type's own `morph::forms::schemaJson<Row>()`,
291/// parsed into a DOM.
292/// @param field Wire field name on the row type.
293/// @param label Column header.
294/// @param hidden Whether the column is present in the row model but not
295/// displayed.
296/// @return The `v-columns` entry.
297[[nodiscard]] inline glz::generic_u64 buildColumnEntry(glz::generic_u64 const& rowDom, std::string const& field,
298 std::string const& label, bool hidden) {
299 glz::generic_u64 entry{};
300 entry["field"] = field;
301 entry["label"] = label;
302 if (hidden) {
303 entry["v-hidden"] = true;
304 }
305 // Deliberately still a subscript, and one of only two reads in this file
306 // that are: `"properties"` is guaranteed present rather than merely
307 // likely. glaze's schema writer emits the key unconditionally for every
308 // reflectable aggregate (measured -- see deriveColumns below), and
309 // deriveColumns, this function's only caller, has already returned "[]"
310 // if it were absent. A findMember null check here would add a second
311 // branch nothing can take -- untestable code and a future branch-coverage
312 // allowlist entry, which is a cost not worth paying for a check that can
313 // never fire (see `docs/spec/forms/forms.md`, "Reading the DOM with
314 // `findMember`").
315 auto const& propsObj = rowDom["properties"].get_object();
316 auto iter = propsObj.find(field);
317 if (iter == propsObj.end()) {
318 return entry; // declared/derived field not on the row type: bare column, no crash
319 }
320 auto const& prop = iter->second;
321 if (auto const* const decimals = ::morph::forms::detail::findMember(prop, "x-decimalPlaces")) {
322 // A *write* into the entry being built: `operator[]`'s insert is the
323 // behaviour wanted, exactly as for `forms.hpp`'s writes. Converting a
324 // write to a null check would change behaviour rather than make it
325 // safe. The directive is here, and on the two below, because
326 // `clang-tidy-diff` reports on changed lines and these lines sit in
327 // the diff alongside the read beside them.
328 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
329 entry["x-decimalPlaces"] = *decimals;
330 }
331 // A Quantity field's ExtUnits sits directly on the property node when its
332 // Quantity type occurs only once in Row (glaze inlines a single-use
333 // struct schema in place); glaze only promotes the schema to a `$defs`
334 // entry (referenced back via `$ref`) when the *same* Quantity type
335 // occurs on more than one property (see forms.md's "CFSharedDefFields"
336 // fixture) — deriveColumns must read whichever shape schemaJson<Row>()
337 // actually produced for this particular row type.
338 // What a table needs beyond the forms view: the column's kind, the
339 // schema's title (the header a table shows when no label is declared),
340 // the unit alternatives a quantity column offers, the values of a closed
341 // set, and the name of a custom comparator. Each is a write into the
342 // entry being built; see the directive above.
343 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
344 entry["kind"] = std::string{inferColumnKind(rowDom, prop)};
345 for (std::string_view const key : {"title", "x-unitAlternatives", "x-comparator"}) {
346 if (auto const* const value = ::morph::forms::detail::findMember(prop, key)) {
347 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
348 entry[std::string{key}] = *value;
349 }
350 }
351 auto const& resolved = *resolveSchemaRef(rowDom, prop);
352 if (auto const* const values = ::morph::forms::detail::findMember(resolved, "enum")) {
353 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
354 entry["enum"] = *values;
355 } else if (auto const* const oneOf = ::morph::forms::detail::findMember(resolved, "oneOf");
356 oneOf != nullptr && oneOf->is_array()) {
357 glz::generic_u64::array_t constants;
358 for (auto const& option : oneOf->get_array()) {
359 if (auto const* const constant = ::morph::forms::detail::findMember(option, "const")) {
360 constants.push_back(*constant);
361 }
362 }
363 if (!constants.empty()) {
364 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
365 entry["enum"] = constants;
366 }
367 }
368 if (auto const* const units = ::morph::forms::detail::findMember(prop, "ExtUnits")) {
369 // A write; see the directive above.
370 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
371 entry["ExtUnits"] = *units;
372 } else if (auto const* const defUnits = ::morph::forms::detail::findMember(resolved, "ExtUnits")) {
373 // A write; see the directive above.
374 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
375 entry["ExtUnits"] = *defUnits;
376 }
377 return entry;
378}
379
380/// @brief Derives (or, when `V::columns` is declared, overrides) the
381/// `v-columns` array for row type @p Row, reusing
382/// `morph::forms::schemaJson<Row>()` as the single source of each
383/// field's declaration order, `x-decimalPlaces`, and `ExtUnits` — the
384/// same schema a standalone form for `Row` would carry.
385/// @tparam V View descriptor (only `HasColumnOverrides<V>` is consulted).
386/// @tparam Row The query action's row element type (a plain, reflectable,
387/// default-constructible aggregate).
388/// @return The `v-columns` array, JSON-encoded.
389template <typename V, typename Row>
390[[nodiscard]] std::string deriveColumns() {
391 glz::generic_u64 rowDom{};
392 // Both halves of this condition are untestable dead code, kept only as
393 // defense-in-depth (cheaper to leave in than to prove absent at every
394 // call site). The `glz::read_json` half guards against `schemaJson<Row>()`
395 // returning text that isn't valid JSON at all -- only possible if
396 // `glz::write_json_schema<Row>()` itself fails (forms.md, "Total schema
397 // failure yields an empty string"), which glaze's own schema writer does
398 // not do for a real, compilable, default-constructible reflectable
399 // aggregate. The `!rowDom.contains("properties")` half was once believed
400 // reachable for a Row with no reflected members (VtEmptyRow in
401 // test_views.cpp) -- direct measurement disproved that: glaze's schema
402 // writer emits a `"properties":{}` key unconditionally for every
403 // reflectable aggregate, including a zero-member one (confirmed via a
404 // standalone `glz::write_json_schema<T>()` probe: `{"type":"object",
405 // "properties":{},"additionalProperties":false,"title":"T"}`), so
406 // `rowDom` always has a `"properties"` entry regardless of Row's member
407 // count. `VtEmptyRow`'s `"[]"` result comes from the ordinary
408 // fallthrough below (an empty `properties` object iterates zero entries,
409 // producing an empty `columns` array), not from this early return --
410 // `llvm-cov` confirms 0 hits on this branch's body across all 10
411 // instantiations this suite exercises.
412 if (glz::read_json(rowDom, ::morph::forms::schemaJson<Row>()) || !rowDom.contains("properties")) {
413 return "[]";
414 }
415 glz::generic_u64::array_t columns{};
416 if constexpr (HasColumnOverrides<V>) {
417 for (auto const& colOverride : V::columns) {
418 auto const label =
419 colOverride.label.empty() ? std::string{colOverride.field} : std::string{colOverride.label};
420 columns.emplace_back(buildColumnEntry(rowDom, std::string{colOverride.field}, label, colOverride.hidden));
421 }
422 } else {
423 // Guaranteed present -- the early return above has already tested it.
424 // See buildColumnEntry for why this stays a subscript.
425 auto const& propsObj = rowDom["properties"].get_object();
426 std::vector<std::pair<std::string, std::uint64_t>> ordered;
427 ordered.reserve(propsObj.size());
428 for (auto const& [name, prop] : propsObj) {
429 // A member the schema hides is not a column; an explicit override
430 // list may still name it.
431 if (auto const* const hiddenNode = ::morph::forms::detail::findMember(prop, "x-hidden");
432 hiddenNode != nullptr && hiddenNode->is_boolean() && hiddenNode->get_boolean()) {
433 continue;
434 }
435 auto const* const orderNode = ::morph::forms::detail::findMember(prop, "x-order");
436 auto const order = (orderNode == nullptr) ? 0 : orderNode->template get<std::uint64_t>();
437 ordered.emplace_back(name, order);
438 }
439 std::ranges::sort(ordered, [](auto const& a, auto const& b) { return a.second < b.second; });
440 for (auto const& [name, order] : ordered) {
441 static_cast<void>(order);
442 columns.emplace_back(buildColumnEntry(rowDom, name, name, false));
443 }
444 }
445 return glz::write_json(columns).value_or("[]");
446}
447
448/// @brief Builds one `v-rowAction` / `v-actions` entry from an
449/// `ActionDescriptor`.
450/// @param descriptor The descriptor to serialise.
451/// @param includeButtonFields `true` for `v-actions` entries (adds `label`,
452/// `scope`, and `confirm` when set); `false` for
453/// `v-rowAction` (only `action` and `bind`).
454/// @return The JSON node.
455[[nodiscard]] inline glz::generic_u64 buildActionNode(ActionDescriptor const& descriptor, bool includeButtonFields) {
456 glz::generic_u64 node{};
457 node["action"] = std::string{descriptor.actionTypeId};
458 if (includeButtonFields) {
459 node["label"] = std::string{descriptor.label.empty() ? descriptor.actionTypeId : descriptor.label};
460 node["scope"] = std::string{descriptor.scope == ActionScope::Row ? "row" : "collection"};
461 if (descriptor.confirm) {
462 node["confirm"] = true;
463 }
464 }
465 if (!descriptor.bind.empty()) {
466 glz::generic_u64 bindNode{};
467 for (auto const& entry : descriptor.bind) {
468 bindNode[std::string{entry.actionField}] = std::string{entry.rowField};
469 }
470 node["bind"] = bindNode;
471 }
472 return node;
473}
474
475/// @brief Builds the complete `viewSchemaJson<V>()` document.
476/// @tparam V View descriptor.
477/// @return The view-schema JSON, or an empty string on internal DOM failure
478/// (schema generation never throws — see docs/spec/forms/forms.md).
479template <typename V>
480[[nodiscard]] std::string buildViewSchema() {
481 using Query = typename V::query;
482 using Result = typename ::morph::model::ActionTraits<Query>::Result;
483
484 glz::generic_u64 dom{};
485 dom["v-kind"] = std::string{viewKindName<typename V::kind>};
486 dom["v-query"] = std::string{::morph::model::ActionTraits<Query>::typeId()};
487 if constexpr (HasViewTitle<V>) {
488 dom["v-title"] = std::string{V::title};
489 } else {
490 dom["v-title"] = std::string{::morph::model::ActionTraits<Query>::typeId()};
491 }
492 if constexpr (HasRowKey<V>) {
493 dom["v-rowKey"] = std::string{V::rowKey};
494 } else {
495 dom["v-rowKey"] = std::string{"id"};
496 }
497
498 // The row element type is the query result itself when it is a
499 // std::vector<...>, else its first array-valued member, in declaration
500 // order — the same two shapes a Choice reads its option rows from.
501 std::string columnsJson = "[]";
502 if constexpr (isStdVector<Result>) {
503 using Row = typename std::remove_cvref_t<Result>::value_type;
504 columnsJson = deriveColumns<V, Row>();
505 } else {
506 Result probe{};
507 bool found = false;
508 ::morph::forms::detail::forEachNamedMember(probe,
509 [&]<std::size_t I>(std::string_view name, const auto& member) {
510 static_cast<void>(name);
511 static_cast<void>(I);
512 using Member = std::remove_cvref_t<decltype(member)>;
513 if constexpr (isStdVector<Member>) {
514 if (!found) {
515 using Row = typename Member::value_type;
516 columnsJson = deriveColumns<V, Row>();
517 found = true;
518 }
519 }
520 });
521 }
522 glz::generic_u64 columnsDom{};
523 if (!glz::read_json(columnsDom, columnsJson)) {
524 dom["v-columns"] = columnsDom;
525 }
526
527 if constexpr (HasRowActionDescriptor<V>) {
528 dom["v-rowAction"] = buildActionNode(V::rowAction, false);
529 }
530 if constexpr (HasViewActions<V>) {
531 glz::generic_u64::array_t actionsArray{};
532 for (auto const& descriptor : V::actions) {
533 actionsArray.emplace_back(buildActionNode(descriptor, true));
534 }
535 dom["v-actions"] = actionsArray;
536 }
537
538 return glz::write_json(dom).value_or(std::string{});
539}
540
541} // namespace detail
542
543/// @brief Generates the view-schema JSON document for view descriptor @p V.
544///
545/// A **new, separate top-level document** from `morph::forms::schemaJson<A>()`
546/// — never merged into any action schema. Computed once per type and cached,
547/// exactly like `schemaJson<A>()` — and, like it, returned **by reference**:
548/// the cache is created once per type per process, never mutated afterwards,
549/// and lives until the process exits, so the reference stays valid for as long
550/// as any caller could hold it. A caller that needs its own mutable copy asks
551/// for one (`std::string mine = viewSchemaJson<V>();`).
552/// @tparam V View descriptor: `using kind = CollectionView` (or
553/// `MasterDetailView`); `using query = <registered query action>`;
554/// optional `static constexpr std::string_view title`, `rowKey`,
555/// `std::array<ColumnOverride, N> columns`,
556/// `ActionDescriptor rowAction`, and
557/// `std::array<ActionDescriptor, N> actions`.
558/// @return Reference to the process-lifetime view-schema JSON for `V`.
559template <typename V>
560[[nodiscard]] const std::string& viewSchemaJson() {
561 static const std::string cached = detail::buildViewSchema<V>();
562 return cached;
563}
564
565/// @brief Traits specialisation that maps a view descriptor type to its
566/// string type-id. Specialise via `BRIDGE_REGISTER_VIEW`.
567/// @tparam V Concrete view descriptor type.
568template <typename V>
570
571/// @brief Process-level registry mapping a view's string type-id to its
572/// `viewSchemaJson<V>()` provider, so a controller can enumerate every
573/// registered view by name — parallels `ActionExecuteRegistry` for
574/// actions (docs/spec/core/bridge.md), but a view registers no
575/// executor, only a schema provider.
577public:
578 /// @brief Registers @p V's schema provider under @p viewId. A second
579 /// registration for the same @p viewId silently replaces the
580 /// first (last-write-wins, the same policy `ActionDispatcher`
581 /// and `ModelRegistryFactory` use — see docs/spec/core/registry.md).
582 /// @tparam V Concrete view descriptor type.
583 /// @param viewId String type-id (`ViewTraits<V>::typeId()`).
584 template <typename V>
585 void registerView(std::string_view viewId) {
586 // `-> const std::string&` explicitly: a deduced return type would decay
587 // the reference to a value and reintroduce, inside the registry, the
588 // per-call copy `viewSchemaJson<V>()` no longer makes.
589 _providers.insert_or_assign(std::string{viewId}, []() -> const std::string& { return viewSchemaJson<V>(); });
590 }
591
592 /// @brief Returns the cached `viewSchemaJson<V>()` for @p viewId.
593 ///
594 /// Forwards the provider's reference through unchanged, so enumerating every
595 /// registered view copies no schema text; the referent is
596 /// `viewSchemaJson<V>()`'s process-lifetime cache.
597 /// @param viewId String type-id previously passed to `registerView`.
598 /// @return Reference to the process-lifetime view-schema JSON registered
599 /// under @p viewId.
600 /// @throws std::runtime_error if no view is registered under @p viewId.
601 [[nodiscard]] const std::string& schemaJson(std::string_view viewId) const {
602 auto iter = _providers.find(std::string{viewId});
603 if (iter == _providers.end()) {
604 throw std::runtime_error("unknown view: " + std::string{viewId});
605 }
606 return iter->second();
607 }
608
609 /// @brief Returns every registered view id, in unspecified order.
610 /// @return The registered view ids.
611 [[nodiscard]] std::vector<std::string> viewIds() const {
612 std::vector<std::string> ids;
613 ids.reserve(_providers.size());
614 for (auto const& entry : _providers) {
615 ids.push_back(entry.first);
616 }
617 return ids;
618 }
619
620 /// @brief Returns the process-level singleton registry.
621 /// @return Reference to the singleton.
623 static ViewRegistry inst;
624 return inst;
625 }
626
627private:
628 std::unordered_map<std::string, std::function<const std::string&()>> _providers;
629};
630
631namespace detail {
632
633/// @brief Static-init helper for `BRIDGE_REGISTER_VIEW`.
634/// @tparam V Concrete view descriptor type.
635/// @param viewId String type-id to register @p V under.
636/// @return Always `true` (so it can be assigned to an anonymous-namespace
637/// `const bool` static initializer).
638template <typename V>
639inline bool registerViewOnce(std::string_view viewId) noexcept {
641 return true;
642}
643
644} // namespace detail
645
646} // namespace morph::views
647
648// NOLINTBEGIN(cppcoreguidelines-macro-usage) — registration macro is the intended public API
649// NOLINTBEGIN(bugprone-macro-parentheses)
650
651/// @brief Registers view descriptor @p V with the string id @p NAME.
652///
653/// Specialises `morph::views::ViewTraits<V>` and registers @p V's
654/// `viewSchemaJson<V>()` provider with the process-level `ViewRegistry` at
655/// static-init time — parallels `BRIDGE_REGISTER_ACTION` (registry.hpp), but
656/// a view has no dispatch path: it is metadata only, so this macro needs
657/// only `<morph/core/registry.hpp>`, never `<morph/core/bridge.hpp>`.
658/// @param V Concrete, unqualified view descriptor type in scope at the
659/// call site (bring a namespaced type into scope with
660/// `using ns::V;` first — this macro pastes `V` into an
661/// identifier, so it cannot be namespace-qualified).
662/// @param NAME String literal used as the view's type-id.
663// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
664#define BRIDGE_REGISTER_VIEW(V, NAME) \
665 template <> \
666 struct morph::views::ViewTraits<V> { \
667 static constexpr std::string_view typeId() noexcept { return NAME; } \
668 }; \
669 namespace { \
670 [[maybe_unused]] const bool bridge_view_reg_##V = morph::views::detail::registerViewOnce<V>(NAME); \
671 }
672// clang-format on
673
674// NOLINTEND(bugprone-macro-parentheses)
675// NOLINTEND(cppcoreguidelines-macro-usage)
Handle< T > ref(std::string const &path)
A named read, {"ref": "name.path"}: a param, state, let, query value, element value or bound name.
Definition author.hpp:1137
Handle< std::string > format(AnyHandle const &pattern, std::vector< AnyHandle > args)
format: a template with {0}, {1}, … placeholders.
Definition author.hpp:970
AnyHandle prop(std::string const &name)
A prop of a component, inside the component's fallback.
Definition author.hpp:1228
Process-level registry mapping a view's string type-id to its viewSchemaJson<V>() provider,...
Definition views.hpp:576
void registerView(std::string_view viewId)
Registers V's schema provider under viewId. A second registration for the same viewId silently replac...
Definition views.hpp:585
std::vector< std::string > viewIds() const
Returns every registered view id, in unspecified order.
Definition views.hpp:611
const std::string & schemaJson(std::string_view viewId) const
Returns the cached viewSchemaJson<V>() for viewId.
Definition views.hpp:601
static ViewRegistry & instance()
Returns the process-level singleton registry.
Definition views.hpp:622
@ Row
On every row; the body is built from the row's bind entries.
Resolved const & resolved()
The process registries, matched and validated on the first call.
Definition effects.hpp:511
constexpr bool oneOf(std::array< std::string_view, N > const &names, std::string_view name) noexcept
Whether a name is one of a closed set.
@ Result
{"result": "m"}: a mutation's last result.
JSON-Forms-style schema generation for morph actions.
Traits specialisation that maps an action type to its id, JSON codec, and result type.
Definition registry.hpp:48
One action a view screen can fire — the row opener (v-rowAction) or an entry of v-actions.
Definition views.hpp:57
std::span< const BindEntry > bind
Field-name mapping applied to prefill the target action's draft from the activating row....
Definition views.hpp:67
ActionScope scope
Row button vs. collection-wide button.
Definition views.hpp:63
std::string_view actionTypeId
Registered type id of the target action.
Definition views.hpp:59
std::string_view label
Button label. Empty means "use the action type id as-is".
Definition views.hpp:61
bool confirm
Whether a renderer must confirm before firing this action.
Definition views.hpp:69
Maps one target-action wire field to a row wire field, so a row's current value prefills the action's...
Definition views.hpp:42
std::string_view rowField
Wire field name on the query action's row type.
Definition views.hpp:46
std::string_view actionField
Wire field name on the target action.
Definition views.hpp:44
Tag type: V::kind selects a plain list/table screen.
Definition views.hpp:117
Declare-to-override entry for one derived column of a collection view.
Definition views.hpp:107
std::string_view field
Wire field name on the query action's row type.
Definition views.hpp:109
bool hidden
Column present in the row model but not displayed.
Definition views.hpp:113
std::string_view label
Column header. Empty means "use `field` as-is".
Definition views.hpp:111
Tag type: V::kind selects a list + inline/side editor screen. Introduces no JSON keys beyond Collecti...
Definition views.hpp:122
Traits specialisation that maps a view descriptor type to its string type-id. Specialise via BRIDGE_R...
Definition views.hpp:569
consteval ActionDescriptor describeAction(std::string_view label={}, ActionScope scope=ActionScope::Row, std::span< const BindEntry > bind={}, bool confirm=false)
Builds an ActionDescriptor for Action, resolving its wire type id from the real, registered ActionTra...
Definition views.hpp:87
ActionScope
Where a view action button appears: opened per row, or once for the whole collection.
Definition views.hpp:38
const std::string & viewSchemaJson()
Generates the view-schema JSON document for view descriptor V.
Definition views.hpp:560