26#include "../../attributes.hpp"
27#include "../../core/completion.hpp"
38namespace morph::forms {
77 std::vector<std::pair<std::string, std::string>>
bind;
83using SchemaLookup = std::function<std::optional<std::string>(std::string_view actionType)>;
86class CollectionModel {
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"});
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")"});
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")"});
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")) {
117 return std::unexpected(
SchemaError{.path =
"v-columns", .message = R
"("v-columns" must be an array)"});
119 for (Json
const& entry :
columns->get_array()) {
120 auto column = columnOf(entry);
122 return std::unexpected(column.error());
124 model._columns.push_back(*std::move(column));
127 if (Json
const*
const rowAction = detail::engine::member(*dom,
"v-rowAction")) {
128 auto action = actionOf(*
rowAction,
"v-rowAction");
130 return std::unexpected(action.error());
132 model._rowAction = *std::move(action);
134 if (Json
const*
const actions = detail::engine::member(*dom,
"v-actions")) {
136 return std::unexpected(
SchemaError{.path =
"v-actions", .message = R
"("v-actions" must be an array)"});
138 for (Json
const& entry :
actions->get_array()) {
139 auto action = actionOf(entry,
"v-actions");
141 return std::unexpected(action.error());
143 model._actions.push_back(*std::move(action));
155 [[nodiscard]] std::string_view
title() const noexcept {
return _title; }
159 [[nodiscard]] std::string_view
query() const noexcept {
return _query; }
163 [[nodiscard]] std::string_view
rowKey() const noexcept {
return _rowKey; }
167 [[nodiscard]] std::span<CollectionColumn const>
columns() const noexcept {
return _columns; }
171 [[nodiscard]] std::optional<CollectionAction>
const&
rowAction() const noexcept {
return _rowAction; }
175 [[nodiscard]] std::span<CollectionAction const>
actions() const noexcept {
return _actions; }
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")"});
186 CollectionColumn column{};
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 +
"\""});
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"))) {
203 static_cast<std::uint32_t
>(std::min<std::uint64_t>(*places, math::kMaxDecimalPlaces));
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()) {
210 }
else if (ascii !=
nullptr) {
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")"});
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);
246 std::vector<CollectionColumn> _columns;
247 std::optional<CollectionAction> _rowAction;
248 std::vector<CollectionAction> _actions;
279namespace detail::engine {
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()) {
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;
303 return displayText(*value);
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{};
317 if (
auto const*
const flag = value->get_if<
bool>()) {
320 if (
auto const*
const text = value->get_if<std::string>()) {
323 if (
auto const*
const fraction = value->get_if<
double>()) {
324 if (
column.kind == table::ColumnKind::Number) {
327 return numberText(*value).value_or(std::string{});
329 if (value->get_if<std::int64_t>() !=
nullptr || value->get_if<std::uint64_t>() !=
nullptr) {
330 if (
auto const whole = SchemaReader::signedOf(value)) {
333 return numberText(*value).value_or(std::string{});
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))}};
345 return toJson(*value);
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 = {}});
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(); }},
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()}}} {
392 throw std::invalid_argument{
"CollectionSession: a Submitter is required"};
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)) {
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(); });
418 [[nodiscard]] std::vector<CollectionRow>
const&
rows()
const {
return _rows.get(); }
423 [[nodiscard]] std::shared_ptr<table::VectorSource>
const&
source() const noexcept {
return _source; }
427 [[nodiscard]]
bool loading()
const {
return _list.pending(); }
431 [[nodiscard]] std::exception_ptr
listError()
const {
return _list.error(); }
442 void fire(std::size_t action, std::string_view rowKey = {}) {
443 CollectionAction const& entry = detail::engine::elementAt(_model.actions(), action);
445 _pending.set(
PendingConfirm{.action = action, .rowKey = std::string{rowKey}});
453 [[nodiscard]] std::optional<PendingConfirm>
const&
pendingConfirm()
const {
return _pending.get(); }
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);
471 void open(std::string_view rowKey) {
476 std::string
const body = bindBody(*_model.rowAction(), rowKey);
478 _openRow.set(std::string{rowKey});
479 _editor->prefill(body);
484 void close() { _openRow.set(std::nullopt); }
488 [[nodiscard]] std::optional<std::string>
const&
openRow()
const {
return _openRow.get(); }
500 [[nodiscard]] std::exception_ptr
actionError()
const {
return _actions.error(); }
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;
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()) {
525 if (list ==
nullptr) {
528 for (Json
const& row :
list->get_array()) {
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);
539 entry.cells.push_back(detail::engine::cellText(row, column));
542 rows.push_back(std::move(entry));
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());
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());
559 line.push_back(dom ? detail::engine::tableCell(*dom, column) : table::Cell{});
561 cells.push_back(std::move(line));
563 _source->setRows(std::move(ids), std::move(cells));
566 [[nodiscard]] std::string bindBody(
CollectionAction const& action, std::string_view rowKey)
const {
567 std::vector<CollectionRow>
const& rows = _rows.peek();
569 auto const row = found == rows.end() ? std::nullopt : detail::engine::parseJson(found->json);
570 std::string body =
"{";
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));
584 _actions.run(Call{.action = action.action, .body = std::move(body)});
589 std::uint64_t
const successes = _editor->successCount();
590 if (successes == _editorSuccesses) {
593 _editorSuccesses = successes;
596 _openRow.set(std::nullopt);
604 reactive::Runtime* _rt;
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;
Node text(TextOptions const &options)
A text node.
AnyHandle list(std::vector< AnyHandle > const &items)
{"list": [...]}: a list built from expressions.
Node column(Nodes const &children, ContainerOptions const &options={})
A column node.
Handle< T > row()
The innermost row, {"row": ""}.
Command run(MutationHandle< Action > const &mutation, Body< Action > const &body={}, RunOptions const &options={})
run: runs a mutation.
std::vector< TableColumn > cells(std::vector< Node > const &cells)
Columns from cells alone, without ids or labels.
The owner of a signal graph: every node takes one, and every operation runs on its owner.
Json const & json() const
The expression's JSON.
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>(...
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.
Strong type for a decimal-precision count.
Strong type for a Rational denominator.
Strong type for a Rational numerator.
Exact rational number with a runtime decimal-precision tag.
Exact text helpers the forms engine shares: JSON quoting and reading, canonical decimal comparison an...