morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
collection.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4
5/// @file forms/engine/collection.hpp
6/// @brief Collections, headless: a `v-*` view document as a reactive list of rows, the table source the table
7/// engine sorts and filters, row and collection actions, the confirm step and the row editor.
8///
9/// Specified in `docs/spec/forms/engine.md`, "Collections".
10
11#include <algorithm>
12#include <cstddef>
13#include <cstdint>
14#include <exception>
15#include <expected>
16#include <functional>
17#include <memory>
18#include <optional>
19#include <span>
20#include <stdexcept>
21#include <string>
22#include <string_view>
23#include <utility>
24#include <vector>
25
26#include "../../attributes.hpp"
27#include "../../core/completion.hpp"
33#include "detail/text.hpp"
34#include "field_model.hpp"
35#include "form_session.hpp"
36#include "rules.hpp"
37
38namespace morph::forms {
39
40/// @brief How a collection presents its row editor.
41enum class CollectionKind : std::uint8_t {
42 Collection, ///< `v-kind: collection`: the editor is a dialog.
43 MasterDetail ///< `v-kind: master-detail`: the editor sits beside the table.
44};
45
46/// @brief Where an action's button sits.
47enum class CollectionActionScope : std::uint8_t {
48 Row, ///< On every row; the body is built from the row's `bind` entries.
49 Collection ///< Once, beside the title; the body is `{}`.
50};
51
52/// @brief One `v-columns` entry.
54 /// @brief The row member the column reads.
55 std::string field;
56 /// @brief The header: `label`, else `field`.
57 std::string label;
58 /// @brief The column's table kind (`kind`, by `table::ColumnKind`'s JSON names); `Text` when absent.
60 /// @brief `v-hidden`: carried in the rows and the table source, not shown.
61 bool hidden = false;
62 /// @brief `x-decimalPlaces`: the places an exact value without its own `dp` is shown with.
63 std::optional<std::uint32_t> decimalPlaces;
64 /// @brief The unit appended to an exact value (`ExtUnits.unitUnicode`, else `unitAscii`).
65 std::string unit;
66};
67
68/// @brief One `v-rowAction` or `v-actions` entry.
70 /// @brief The action type executed.
71 std::string action;
72 /// @brief The button label: `label`, else the action type.
73 std::string label;
74 /// @brief Row or collection.
76 /// @brief `{actionField: rowField}` pairs, in document order.
77 std::vector<std::pair<std::string, std::string>> bind;
78 /// @brief Whether the action waits for a confirmation.
79 bool confirm = false;
80};
81
82/// @brief Finds an action's schema JSON by its type id (a registry, a map, a served catalog).
83using SchemaLookup = std::function<std::optional<std::string>(std::string_view actionType)>;
84
85/// @brief The immutable model of a `v-*` view document.
86class CollectionModel {
87public:
88 /// @brief Reads a view document.
89 /// @param viewJson The document, in the vocabulary `views::viewSchemaJson<V>()` emits.
90 /// @return The model, or the error naming the malformed key.
91 // NOLINTNEXTLINE(readability-function-cognitive-complexity): one check per `v-*` key, in document order.
92 [[nodiscard]] static std::expected<CollectionModel, SchemaError> fromSchema(std::string_view viewJson) {
93 using detail::engine::Json;
94 auto const dom = detail::engine::parseJson(viewJson);
95 if (!dom || !dom->is_object()) {
96 return std::unexpected(SchemaError{.path = "", .message = "a view document must be a JSON object"});
97 }
98 CollectionModel model{};
99 auto const* const kind = detail::engine::stringAt(*dom, "v-kind");
100 if (kind != nullptr && *kind != "collection" && *kind != "master-detail") {
101 return std::unexpected(
102 SchemaError{.path = "v-kind", .message = R"("v-kind" must be "collection" or "master-detail")"});
103 }
104 model._kind =
105 (kind != nullptr && *kind == "master-detail") ? CollectionKind::MasterDetail : CollectionKind::Collection;
106 auto const* const query = detail::engine::stringAt(*dom, "v-query");
107 if (query == nullptr) {
108 return std::unexpected(SchemaError{.path = "v-query", .message = R"(a view document needs a "v-query")"});
109 }
110 model._query = *query;
111 auto const* const title = detail::engine::stringAt(*dom, "v-title");
112 model._title = title != nullptr ? *title : model._query;
113 auto const* const rowKey = detail::engine::stringAt(*dom, "v-rowKey");
114 model._rowKey = rowKey != nullptr ? *rowKey : std::string{"id"};
115 if (Json const* const columns = detail::engine::member(*dom, "v-columns")) {
116 if (!columns->is_array()) {
117 return std::unexpected(SchemaError{.path = "v-columns", .message = R"("v-columns" must be an array)"});
118 }
119 for (Json const& entry : columns->get_array()) {
120 auto column = columnOf(entry);
121 if (!column) {
122 return std::unexpected(column.error());
123 }
124 model._columns.push_back(*std::move(column));
125 }
126 }
127 if (Json const* const rowAction = detail::engine::member(*dom, "v-rowAction")) {
128 auto action = actionOf(*rowAction, "v-rowAction");
129 if (!action) {
130 return std::unexpected(action.error());
131 }
132 model._rowAction = *std::move(action);
133 }
134 if (Json const* const actions = detail::engine::member(*dom, "v-actions")) {
135 if (!actions->is_array()) {
136 return std::unexpected(SchemaError{.path = "v-actions", .message = R"("v-actions" must be an array)"});
137 }
138 for (Json const& entry : actions->get_array()) {
139 auto action = actionOf(entry, "v-actions");
140 if (!action) {
141 return std::unexpected(action.error());
142 }
143 model._actions.push_back(*std::move(action));
144 }
145 }
146 return model;
147 }
148
149 /// @brief How the row editor is presented.
150 /// @return The kind.
151 [[nodiscard]] CollectionKind kind() const noexcept { return _kind; }
152
153 /// @brief `v-title`, or the query's type.
154 /// @return The title.
155 [[nodiscard]] std::string_view title() const noexcept { return _title; }
156
157 /// @brief `v-query`: the action whose result rows are listed.
158 /// @return The action type.
159 [[nodiscard]] std::string_view query() const noexcept { return _query; }
160
161 /// @brief `v-rowKey`: the row member that identifies a row (`id` by default).
162 /// @return The member name.
163 [[nodiscard]] std::string_view rowKey() const noexcept { return _rowKey; }
164
165 /// @brief `v-columns`, hidden ones included.
166 /// @return The columns.
167 [[nodiscard]] std::span<CollectionColumn const> columns() const noexcept { return _columns; }
168
169 /// @brief `v-rowAction`: the editor's action and its bind entries.
170 /// @return The row action, or `std::nullopt`.
171 [[nodiscard]] std::optional<CollectionAction> const& rowAction() const noexcept { return _rowAction; }
172
173 /// @brief `v-actions`.
174 /// @return The actions.
175 [[nodiscard]] std::span<CollectionAction const> actions() const noexcept { return _actions; }
176
177private:
178 CollectionModel() = default;
179
180 [[nodiscard]] static std::expected<CollectionColumn, SchemaError> columnOf(detail::engine::Json const& entry) {
181 using detail::engine::Json;
182 auto const* const field = detail::engine::stringAt(entry, "field");
183 if (field == nullptr) {
184 return std::unexpected(SchemaError{.path = "v-columns", .message = R"(a column needs a "field")"});
185 }
186 CollectionColumn column{};
187 column.field = *field;
188 auto const* const label = detail::engine::stringAt(entry, "label");
189 column.label = label != nullptr ? *label : column.field;
190 if (auto const* const kind = detail::engine::stringAt(entry, "kind")) {
191 table::ColumnKind parsed{};
192 if (glz::read_json(parsed, detail::engine::quote(*kind))) {
193 return std::unexpected(SchemaError{.path = "v-columns." + column.field,
194 .message = "unknown column kind \"" + *kind + "\""});
195 }
196 column.kind = parsed;
197 }
198 Json const* const hidden = detail::engine::member(entry, "v-hidden");
199 column.hidden = hidden != nullptr && hidden->is_boolean() && hidden->get<bool>();
200 if (auto const places =
201 detail::engine::SchemaReader::unsignedOf(detail::engine::member(entry, "x-decimalPlaces"))) {
202 column.decimalPlaces =
203 static_cast<std::uint32_t>(std::min<std::uint64_t>(*places, math::kMaxDecimalPlaces));
204 }
205 if (Json const* const units = detail::engine::member(entry, "ExtUnits")) {
206 auto const* const unicode = detail::engine::stringAt(*units, "unitUnicode");
207 auto const* const ascii = detail::engine::stringAt(*units, "unitAscii");
208 if (unicode != nullptr && !unicode->empty()) {
209 column.unit = *unicode;
210 } else if (ascii != nullptr) {
211 column.unit = *ascii;
212 }
213 }
214 return column;
215 }
216
217 [[nodiscard]] static std::expected<CollectionAction, SchemaError> actionOf(detail::engine::Json const& node,
218 std::string const& path) {
219 auto const* const type = detail::engine::stringAt(node, "action");
220 if (type == nullptr) {
221 return std::unexpected(SchemaError{.path = path, .message = R"(an action needs an "action")"});
222 }
223 CollectionAction action{};
224 action.action = *type;
225 auto const* const label = detail::engine::stringAt(node, "label");
226 action.label = label != nullptr ? *label : action.action;
227 auto const* const scope = detail::engine::stringAt(node, "scope");
228 action.scope = (scope != nullptr && *scope == "collection") ? CollectionActionScope::Collection
230 auto const* const confirm = detail::engine::member(node, "confirm");
231 action.confirm = confirm != nullptr && confirm->is_boolean() && confirm->get<bool>();
232 if (auto const* const bind = detail::engine::member(node, "bind"); bind != nullptr && bind->is_object()) {
233 for (auto const& [field, rowField] : bind->get_object()) {
234 if (auto const* const text = rowField.get_if<std::string>()) {
235 action.bind.emplace_back(field, *text);
236 }
237 }
238 }
239 return action;
240 }
241
242 CollectionKind _kind = CollectionKind::Collection;
243 std::string _title;
244 std::string _query;
245 std::string _rowKey;
246 std::vector<CollectionColumn> _columns;
247 std::optional<CollectionAction> _rowAction;
248 std::vector<CollectionAction> _actions;
249};
250
251/// @brief One listed row.
253 /// @brief The row's identity for the table engine: an `int64` key, or its text when it is not one.
255 /// @brief The row key as exact text (every digit kept): what `open`, `fire` and the views address a row by.
256 std::string key;
257 /// @brief The row as JSON, every integer digit kept.
258 std::string json;
259 /// @brief The visible columns' formatted cells, in column order.
260 std::vector<std::string> cells;
261 /// @brief Member-wise equality.
262 /// @param other The other row.
263 /// @return `true` when every member matches.
264 bool operator==(CollectionRow const& other) const = default;
265};
266
267/// @brief A confirm-guarded action waiting for an answer.
269 /// @brief Index into `CollectionModel::actions()`.
270 std::size_t action = 0;
271 /// @brief The row it was fired on (empty for a collection action).
272 std::string rowKey;
273 /// @brief Member-wise equality.
274 /// @param other The other pending action.
275 /// @return `true` when action and row match.
276 bool operator==(PendingConfirm const& other) const = default;
277};
278
279namespace detail::engine {
280
281/// @brief A cell's display text: an exact `{num, den}` value at its `dp` (else the column's places) with the
282/// column's unit, anything else as `displayText`, an absent or null member as empty.
283/// @param row The row.
284/// @param column The column.
285/// @return The text.
286[[nodiscard]] inline std::string cellText(Json const& row, CollectionColumn const& column) {
287 Json const* const value = member(row, column.field);
288 if (value == nullptr || value->is_null()) {
289 return {};
290 }
291 if (value->is_object()) {
292 auto const num = SchemaReader::signedOf(member(*value, "num"));
293 auto const den = SchemaReader::signedOf(member(*value, "den"));
294 if (num && den && *den != 0) {
295 auto const places = SchemaReader::unsignedOf(member(*value, "dp"));
296 std::uint32_t const decimals =
297 places ? static_cast<std::uint32_t>(*places) : column.decimalPlaces.value_or(0);
298 std::string const text = decimalText(
300 return column.unit.empty() ? text : text + " " + column.unit;
301 }
302 }
303 return displayText(*value);
304}
305
306/// @brief A row member as a table cell: an integer as `int64` (its digits as text past int64), an exact value as a
307/// `Rational`, a fraction as a `double` only in a `Number` column (its digits as text otherwise, for the
308/// engine to parse by the column's kind), text, a bool, and absent or null as empty.
309/// @param row The row.
310/// @param column The column.
311/// @return The cell.
312[[nodiscard]] inline table::Cell tableCell(Json const& row, CollectionColumn const& column) {
313 Json const* const value = member(row, column.field);
314 if (value == nullptr || value->is_null()) {
315 return std::monostate{};
316 }
317 if (auto const* const flag = value->get_if<bool>()) {
318 return *flag;
319 }
320 if (auto const* const text = value->get_if<std::string>()) {
321 return *text;
322 }
323 if (auto const* const fraction = value->get_if<double>()) {
324 if (column.kind == table::ColumnKind::Number) {
325 return *fraction;
326 }
327 return numberText(*value).value_or(std::string{});
328 }
329 if (value->get_if<std::int64_t>() != nullptr || value->get_if<std::uint64_t>() != nullptr) {
330 if (auto const whole = SchemaReader::signedOf(value)) {
331 return *whole;
332 }
333 return numberText(*value).value_or(std::string{});
334 }
335 if (value->is_object()) {
336 auto const num = SchemaReader::signedOf(member(*value, "num"));
337 auto const den = SchemaReader::signedOf(member(*value, "den"));
338 if (num && den && *den != 0) {
339 auto const places = SchemaReader::unsignedOf(member(*value, "dp")).value_or(0);
340 return math::Rational{math::Numerator{*num}, math::Denominator{*den},
341 math::DecimalPlaces{static_cast<std::uint32_t>(
342 std::min<std::uint64_t>(places, math::kMaxDecimalPlaces))}};
343 }
344 }
345 return toJson(*value);
346}
347
348/// @brief The table columns of a collection: every `v-columns` entry, hidden ones included, by field and kind.
349/// @param model The collection model.
350/// @return The columns.
351[[nodiscard]] inline std::vector<table::ColumnInfo> tableColumns(CollectionModel const& model) {
352 std::vector<table::ColumnInfo> columns;
353 columns.reserve(model.columns().size());
354 for (CollectionColumn const& column : model.columns()) {
355 columns.push_back(table::ColumnInfo{.id = column.field, .kind = column.kind, .comparator = {}, .format = {}});
356 }
357 return columns;
358}
359
360} // namespace detail::engine
361
362/// @brief The reactive state of one collection: the list, the table source, the actions, the confirm step and the
363/// row editor. A controller in the sense of `docs/spec/reactive/control.md`; it draws nothing.
364///
365/// Non-copyable and non-movable: replies and the editor point into it. Destroy a view of it before the session.
367public:
368 /// @param runtime The runtime. Borrowed: it must outlive the session.
369 /// @param model The view model.
370 /// @param schemas Finds the row action's schema; an unknown or malformed one means the collection has no editor.
371 /// @param submit Executes the query and every action. Required.
372 /// @param choices Fetches the editor's Choice options; may be empty.
373 /// @param options The editor's session options.
374 /// @throws std::invalid_argument when @p submit is empty.
376 SchemaLookup const& schemas, Submitter submit, ChoiceFetcher choices,
377 FormSessionOptions options = {})
378 : _rt{&runtime},
379 _model{std::move(model)},
380 _submit{std::move(submit)},
381 _source{std::make_shared<table::VectorSource>(detail::engine::tableColumns(_model))},
382 _openRow{runtime, std::nullopt},
383 _pending{runtime, std::nullopt},
384 _list{runtime, [this](std::string const& body) { return _submit(_model.query(), body); },
385 [] { return std::optional<std::string>{"{}"}; }},
386 _rows{runtime, [this] { return parseRows(); }},
387 _actions{
388 runtime, [this](Call call) { return _submit(call.action, std::move(call.body)); },
389 reactive::MutationOptions<Call>{
390 .concurrency = reactive::Concurrency::Exclusive, .serialKey = {}, .invalidates = {_list.link()}}} {
391 if (!_submit) {
392 throw std::invalid_argument{"CollectionSession: a Submitter is required"};
393 }
394 _syncSource = std::make_unique<reactive::Effect>(runtime, [this] { syncSource(); });
395 if (_model.rowAction().has_value() && schemas) {
396 if (auto const schema = schemas(_model.rowAction()->action)) {
397 if (auto editorModel = FormModel::fromSchema(_model.rowAction()->action, *schema)) {
398 _editor = std::make_unique<FormSession>(runtime, *std::move(editorModel), _submit,
399 std::move(choices), std::move(options));
400 _editorWatch = std::make_unique<reactive::Effect>(runtime, [this] { watchEditor(); });
401 }
402 }
403 }
404 }
405
406 ~CollectionSession() = default;
407 CollectionSession(CollectionSession const&) = delete;
408 CollectionSession& operator=(CollectionSession const&) = delete;
410 CollectionSession& operator=(CollectionSession&&) = delete;
411
412 /// @brief The view model.
413 /// @return The model.
414 [[nodiscard]] CollectionModel const& model() const noexcept { return _model; }
415
416 /// @brief The listed rows, from the last successful query, in the order the reply gave them. Tracked.
417 /// @return The rows.
418 [[nodiscard]] std::vector<CollectionRow> const& rows() const { return _rows.get(); }
419
420 /// @brief The rows as a table source, one column per `v-columns` entry: what a table engine sorts, filters and
421 /// selects over. Replaced (a `Reset`) on every list reply. Owner-affine, like every `DataSource`.
422 /// @return The source.
423 [[nodiscard]] std::shared_ptr<table::VectorSource> const& source() const noexcept { return _source; }
424
425 /// @brief Whether the list is loading. Tracked.
426 /// @return `true` while the query is in flight.
427 [[nodiscard]] bool loading() const { return _list.pending(); }
428
429 /// @brief The list's last failure. Tracked.
430 /// @return The exception, or null.
431 [[nodiscard]] std::exception_ptr listError() const { return _list.error(); }
432
433 /// @brief Reloads the list.
434 void refresh() { _list.refetch(); }
435
436 /// @brief Fires `model().actions()[action]`, on @p rowKey for a row action. A confirm-guarded action waits in
437 /// `pendingConfirm()` instead. While another action is in flight the call does nothing (the actions are
438 /// one `Exclusive` mutation, and a view disables its buttons while `actionPending()`).
439 /// @param action Index into `model().actions()`.
440 /// @param rowKey The row's exact key text, for a row action.
441 /// @throws std::out_of_range for an index past the actions.
442 void fire(std::size_t action, std::string_view rowKey = {}) {
443 CollectionAction const& entry = detail::engine::elementAt(_model.actions(), action);
444 if (entry.confirm) {
445 _pending.set(PendingConfirm{.action = action, .rowKey = std::string{rowKey}});
446 return;
447 }
448 run(entry, rowKey);
449 }
450
451 /// @brief The action waiting for confirmation. Tracked.
452 /// @return It, or `std::nullopt`.
453 [[nodiscard]] std::optional<PendingConfirm> const& pendingConfirm() const { return _pending.get(); }
454
455 /// @brief Runs the action waiting for confirmation, if any.
456 void confirm() {
457 std::optional<PendingConfirm> const pending = _pending.peek();
458 _pending.set(std::nullopt);
459 if (pending.has_value()) {
460 run(detail::engine::elementAt(_model.actions(), pending->action), pending->rowKey);
461 }
462 }
463
464 /// @brief Drops the action waiting for confirmation: nothing is sent.
465 void cancelConfirm() { _pending.set(std::nullopt); }
466
467 /// @brief Opens the editor on a row, prefilled from the row action's `bind` entries with the row's exact values.
468 /// A prefill replaces every field, so nothing typed for another row carries over. Programmatic, so
469 /// nothing is submitted until the user edits.
470 /// @param rowKey The row's exact key text.
471 void open(std::string_view rowKey) {
472 if (!_editor) {
473 return;
474 }
475 // NOLINTNEXTLINE(bugprone-unchecked-optional-access): an editor exists only for a row action.
476 std::string const body = bindBody(*_model.rowAction(), rowKey);
477 _rt->batch([&] {
478 _openRow.set(std::string{rowKey});
479 _editor->prefill(body);
480 });
481 }
482
483 /// @brief Closes the editor without submitting.
484 void close() { _openRow.set(std::nullopt); }
485
486 /// @brief The row the editor is open on. Tracked.
487 /// @return Its key text, or `std::nullopt`.
488 [[nodiscard]] std::optional<std::string> const& openRow() const { return _openRow.get(); }
489
490 /// @brief The row editor.
491 /// @return The editor, or null when the view has no row action or its schema is unknown or malformed.
492 [[nodiscard]] FormSession* editor() const noexcept { return _editor.get(); }
493
494 /// @brief Whether an action is in flight. Tracked.
495 /// @return `true` while one is.
496 [[nodiscard]] bool actionPending() const { return _actions.pending(); }
497
498 /// @brief The last action failure. Tracked.
499 /// @return The exception, or null; cleared by the next success.
500 [[nodiscard]] std::exception_ptr actionError() const { return _actions.error(); }
501
502private:
503 struct Call {
504 std::string action;
505 std::string body;
506 };
507
508 [[nodiscard]] std::vector<CollectionRow> parseRows() const {
509 using detail::engine::Json;
510 std::vector<CollectionRow> rows;
511 std::optional<std::string> const& reply = _list.value();
512 auto const dom = reply.has_value() ? detail::engine::parseJson(*reply) : std::nullopt;
513 if (!dom) {
514 return rows;
515 }
516 Json const* list = dom->is_array() ? &*dom : nullptr;
517 if (list == nullptr && dom->is_object()) {
518 for (auto const& [ignored, candidate] : dom->get_object()) {
519 if (candidate.is_array()) {
520 list = &candidate;
521 break;
522 }
523 }
524 }
525 if (list == nullptr) {
526 return rows;
527 }
528 for (Json const& row : list->get_array()) {
529 CollectionRow entry{};
530 Json const* const key = detail::engine::member(row, _model.rowKey());
531 entry.key = key == nullptr ? std::to_string(rows.size()) : detail::engine::displayText(*key);
532 auto const whole = key == nullptr ? std::optional<std::int64_t>{static_cast<std::int64_t>(rows.size())}
533 : detail::engine::SchemaReader::signedOf(key);
534 bool const integral = key == nullptr || key->get_if<double>() == nullptr;
535 entry.id = whole && integral ? table::RowId{*whole} : table::RowId{entry.key};
536 entry.json = detail::engine::toJson(row);
537 for (CollectionColumn const& column : _model.columns()) {
538 if (!column.hidden) {
539 entry.cells.push_back(detail::engine::cellText(row, column));
540 }
541 }
542 rows.push_back(std::move(entry));
543 }
544 return rows;
545 }
546
547 void syncSource() {
548 std::vector<CollectionRow> const& rows = _rows.get();
549 std::vector<table::RowId> ids;
550 std::vector<std::vector<table::Cell>> cells;
551 ids.reserve(rows.size());
552 cells.reserve(rows.size());
553 for (CollectionRow const& row : rows) {
554 ids.push_back(row.id);
555 auto const dom = detail::engine::parseJson(row.json);
556 std::vector<table::Cell> line;
557 line.reserve(_model.columns().size());
558 for (CollectionColumn const& column : _model.columns()) {
559 line.push_back(dom ? detail::engine::tableCell(*dom, column) : table::Cell{});
560 }
561 cells.push_back(std::move(line));
562 }
563 _source->setRows(std::move(ids), std::move(cells));
564 }
565
566 [[nodiscard]] std::string bindBody(CollectionAction const& action, std::string_view rowKey) const {
567 std::vector<CollectionRow> const& rows = _rows.peek();
568 auto const found = std::ranges::find(rows, rowKey, &CollectionRow::key);
569 auto const row = found == rows.end() ? std::nullopt : detail::engine::parseJson(found->json);
570 std::string body = "{";
571 bool first = true;
572 for (auto const& [field, rowField] : action.bind) {
573 detail::engine::Json const* const value = row ? detail::engine::member(*row, rowField) : nullptr;
574 body += (first ? "" : ",") + detail::engine::quote(field) + ":" +
575 (value == nullptr ? std::string{"null"} : detail::engine::toJson(*value));
576 first = false;
577 }
578 return body + "}";
579 }
580
581 void run(CollectionAction const& action, std::string_view rowKey) {
582 std::string body =
583 action.scope == CollectionActionScope::Collection ? std::string{"{}"} : bindBody(action, rowKey);
584 _actions.run(Call{.action = action.action, .body = std::move(body)});
585 }
586
587 // Each success of the editor's submission ticks its count: close the editor and reload the list.
588 void watchEditor() {
589 std::uint64_t const successes = _editor->successCount();
590 if (successes == _editorSuccesses) {
591 return;
592 }
593 _editorSuccesses = successes;
594 _rt->untracked([&] {
595 _rt->batch([&] {
596 _openRow.set(std::nullopt);
597 _list.refetch();
598 });
599 });
600 }
601
602 // Declaration order is destruction order reversed: the Effects go first, then the editor, the computeds and
603 // the query that they read.
604 reactive::Runtime* _rt;
605 CollectionModel _model;
606 Submitter _submit;
607 std::shared_ptr<table::VectorSource> _source;
608 reactive::Signal<std::optional<std::string>> _openRow;
609 reactive::Signal<std::optional<PendingConfirm>> _pending;
610 reactive::Query<std::string, std::string> _list;
611 reactive::Computed<std::vector<CollectionRow>> _rows;
612 reactive::Mutation<Call, std::string> _actions;
613 std::unique_ptr<FormSession> _editor;
614 std::uint64_t _editorSuccesses = 0;
615 std::unique_ptr<reactive::Effect> _syncSource;
616 std::unique_ptr<reactive::Effect> _editorWatch;
617};
618
619} // namespace morph::forms
Node text(TextOptions const &options)
A text node.
Definition author.hpp:3126
AnyHandle list(std::vector< AnyHandle > const &items)
{"list": [...]}: a list built from expressions.
Definition author.hpp:1123
Node column(Nodes const &children, ContainerOptions const &options={})
A column node.
Definition author.hpp:3394
Handle< T > row()
The innermost row, {"row": ""}.
Definition author.hpp:1145
Command run(MutationHandle< Action > const &mutation, Body< Action > const &body={}, RunOptions const &options={})
run: runs a mutation.
Definition author.hpp:2755
std::vector< TableColumn > cells(std::vector< Node > const &cells)
Columns from cells alone, without ids or labels.
Definition author.hpp:3686
The immutable model of a v-* view document.
CollectionKind kind() const noexcept
How the row editor is presented.
std::string_view title() const noexcept
v-title, or the query's type.
std::span< CollectionAction const > actions() const noexcept
v-actions.
std::optional< CollectionAction > const & rowAction() const noexcept
v-rowAction: the editor's action and its bind entries.
std::span< CollectionColumn const > columns() const noexcept
v-columns, hidden ones included.
std::string_view query() const noexcept
v-query: the action whose result rows are listed.
static std::expected< CollectionModel, SchemaError > fromSchema(std::string_view viewJson)
Reads a view document.
std::string_view rowKey() const noexcept
v-rowKey: the row member that identifies a row (id by default).
The reactive state of one collection: the list, the table source, the actions, the confirm step and t...
FormSession * editor() const noexcept
The row editor.
void open(std::string_view rowKey)
Opens the editor on a row, prefilled from the row action's bind entries with the row's exact values....
void refresh()
Reloads the list.
std::optional< PendingConfirm > const & pendingConfirm() const
The action waiting for confirmation. Tracked.
bool loading() const
Whether the list is loading. Tracked.
std::exception_ptr listError() const
The list's last failure. Tracked.
std::vector< CollectionRow > const & rows() const
The listed rows, from the last successful query, in the order the reply gave them....
CollectionModel const & model() const noexcept
The view model.
std::shared_ptr< table::VectorSource > const & source() const noexcept
The rows as a table source, one column per v-columns entry: what a table engine sorts,...
CollectionSession(reactive::Runtime &runtime, CollectionModel model, SchemaLookup const &schemas, Submitter submit, ChoiceFetcher choices, FormSessionOptions options={})
std::exception_ptr actionError() const
The last action failure. Tracked.
void cancelConfirm()
Drops the action waiting for confirmation: nothing is sent.
void confirm()
Runs the action waiting for confirmation, if any.
void fire(std::size_t action, std::string_view rowKey={})
Fires model().actions()[action], on rowKey for a row action. A confirm-guarded action waits in pendin...
void close()
Closes the editor without submitting.
std::optional< std::string > const & openRow() const
The row the editor is open on. Tracked.
bool actionPending() const
Whether an action is in flight. Tracked.
static std::expected< FormModel, SchemaError > fromSchema(std::string_view actionType, std::string_view schemaJson, FieldOverlays const &overlays={})
Reads a schema document, with the overlays a document or an application states for its fields.
The reactive state of one form: a controller over a FormModel.
The owner of a signal graph: every node takes one, and every operation runs on its owner.
Definition runtime.hpp:70
Json const & json() const
The expression's JSON.
Definition author.hpp:688
std::function< std::optional< std::string >(std::string_view actionType)> SchemaLookup
Finds an action's schema JSON by its type id (a registry, a map, a served catalog).
CollectionActionScope
Where an action's button sits.
@ Row
On every row; the body is built from the row's bind entries.
@ Collection
Once, beside the title; the body is {}.
CollectionKind
How a collection presents its row editor.
@ MasterDetail
v-kind: master-detail: the editor sits beside the table.
@ Collection
v-kind: collection: the editor is a dialog.
Declarative control: Query, Mutation and Subscription turn server interaction into reactive state,...
The rows a table engine reads, and the services it is given.
std::variant< std::monostate, std::int64_t, math::Rational, double, std::string, bool, QuantityCell > Cell
One cell as a data source hands it out.
ColumnKind
What a column holds, which decides how its keys are built and compared.
@ Text
Text, ordered and matched through the TextCollator.
std::variant< std::int64_t, std::string > RowId
A row's identity: the table's key, stable across reloads.
@ Call
{"fn": "name", "args": [...]}, and {"t": ...}.
FormModel: the immutable runtime model of one action's form, read from the schema JSON schemaJson<A>(...
FormSession: the reactive state of one form over a FormModel — drafts, encoded values,...
std::function< async::Completion< std::string >(std::string_view optionsAction, std::string bodyJson)> ChoiceFetcher
Executes a Choice's options action: the action and its JSON request in, the reply out.
std::function< async::Completion< std::string >(std::string_view actionType, std::string bodyJson)> Submitter
Executes a form's action: the action type and its JSON body in, the JSON reply out.
Exact rational arithmetic for morph model/action values.
x-rules read into RuleExpr trees, translated into the UI document's expression language (expr/1) and ...
morph::reactive::Runtime: the owner, batching and flush scheduling of a signal graph.
Signal<T>, Computed<T> and Effect: the nodes of a reactive graph.
One v-rowAction or v-actions entry.
std::string action
The action type executed.
CollectionActionScope scope
Row or collection.
std::vector< std::pair< std::string, std::string > > bind
{actionField: rowField} pairs, in document order.
bool confirm
Whether the action waits for a confirmation.
std::string label
The button label: label, else the action type.
std::string field
The row member the column reads.
std::optional< std::uint32_t > decimalPlaces
x-decimalPlaces: the places an exact value without its own dp is shown with.
table::ColumnKind kind
The column's table kind (kind, by table::ColumnKind's JSON names); Text when absent.
bool hidden
v-hidden: carried in the rows and the table source, not shown.
std::string label
The header: label, else field.
std::string unit
The unit appended to an exact value (ExtUnits.unitUnicode, else unitAscii).
table::RowId id
The row's identity for the table engine: an int64 key, or its text when it is not one.
std::vector< std::string > cells
The visible columns' formatted cells, in column order.
std::string key
The row key as exact text (every digit kept): what open, fire and the views address a row by.
bool operator==(CollectionRow const &other) const =default
Member-wise equality.
std::string json
The row as JSON, every integer digit kept.
How a session reads numbers, dates and text.
A confirm-guarded action waiting for an answer.
std::string rowKey
The row it was fired on (empty for a collection action).
bool operator==(PendingConfirm const &other) const =default
Member-wise equality.
std::size_t action
Index into CollectionModel::actions().
Why a schema does not read as a form: where, and why.
Definition rules.hpp:34
Strong type for a decimal-precision count.
Definition rational.hpp:121
Strong type for a Rational denominator.
Definition rational.hpp:154
Strong type for a Rational numerator.
Definition rational.hpp:140
Exact rational number with a runtime decimal-precision tag.
Definition rational.hpp:375
Exact text helpers the forms engine shares: JSON quoting and reading, canonical decimal comparison an...