morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
collection_view.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_view.hpp
6/// @brief `collectionView`: a `CollectionSession` rendered as a view tree (`ui::Node`): the title and collection
7/// actions, a table of the rows with their actions, the confirm dialog and the row editor, as a dialog or
8/// beside the table.
9///
10/// Specified in `docs/spec/forms/engine.md`, "Rendering collections and wizards".
11
12#include <cstddef>
13#include <cstdint>
14#include <exception>
15#include <functional>
16#include <memory>
17#include <optional>
18#include <string>
19#include <utility>
20#include <vector>
21
24#include "../../ui/view.hpp"
25#include "collection.hpp"
26#include "form_view.hpp"
27
28namespace morph::forms {
29
30/// @brief Replacements for the parts of a collection view a host draws from its own UI kit. Each one left empty
31/// keeps the built-in part.
33 /// @brief Replaces the title and the collection actions' buttons. Called once, with the session, whose model
34 /// gives the title, columns and actions and whose `fire` runs one.
36 /// @brief Replaces each row's cells and buttons: the rows are then a list of these nodes rather than a table.
37 /// Called once per row mounted, with the session and the row's signal (read it inside bindings).
39 /// @brief Replaces the confirm dialog: mounted while an action waits for confirmation, unmounted once it is
40 /// answered. Called with the session, whose `pendingConfirm`, `confirm` and `cancelConfirm` it uses.
42 /// @brief Replaces the editor dialog of a `collection` view; it is always mounted, and shows the editor while
43 /// `openRow()` is engaged. Called once with the session and the editor's form view, which it places.
44 /// A master-detail view has no dialog and does not call it.
46};
47
48/// @brief How `collectionView` draws a collection and labels its chrome.
50 /// @brief The editor's form view options; its overrides reach the editor's fields.
52 /// @brief The host's replacements for the view's own parts.
54 /// @brief The caption of a row's Open button.
55 std::string openLabel = "Open";
56 /// @brief The confirm dialog's title.
57 std::string confirmTitle = "Confirm";
58 /// @brief The confirm dialog's question.
59 std::string confirmMessage = "Are you sure?";
60 /// @brief The confirm dialog's accept button.
61 std::string acceptLabel = "OK";
62 /// @brief The confirm dialog's cancel button.
63 std::string cancelLabel = "Cancel";
64 /// @brief The built-in header's title (`collection_title`); empty shows the schema's.
66};
67
68namespace detail::engine {
69
70/// @brief The error line of a collection: the list's failure, else the last action's.
71/// @param session The session.
72/// @return `error: <message>`, or empty.
73[[nodiscard]] inline std::string collectionErrorText(CollectionSession const& session) {
74 std::exception_ptr error = session.listError();
75 if (error == nullptr) {
76 error = session.actionError();
77 }
78 return error == nullptr ? std::string{} : "error: " + reactive::errorMessage(error);
79}
80
81/// @brief The indices of a collection's actions with one scope, in document order.
82/// @param model The model.
83/// @param scope The scope.
84/// @return The indices into `model.actions()`.
85[[nodiscard]] inline std::vector<std::size_t> actionsOf(CollectionModel const& model, CollectionActionScope scope) {
86 std::vector<std::size_t> indices;
87 for (std::size_t i = 0; i < model.actions().size(); ++i) {
88 if (elementAt(model.actions(), i).scope == scope) {
89 indices.push_back(i);
90 }
91 }
92 return indices;
93}
94
95/// @brief The built-in header: the title, a busy indicator while the list loads, and the collection actions.
96/// @param session The session.
97/// @param heading The title in place of the schema's; empty shows the schema's.
98/// @return The header.
99[[nodiscard]] inline ui::Node collectionHeader(CollectionSession& session, ui::Prop<std::string> const& heading = {}) {
100 CollectionSession* const view = &session;
101 ui::Text title{.text = isSet(heading) ? heading : ui::Prop<std::string>{std::string{session.model().title()}},
102 .role = ui::TextRole::Heading};
103 title.common.testId = "collection_title";
104 std::vector<ui::Node> children{
105 ui::text(std::move(title)),
106 ui::busy(ui::Busy{.active = [view] { return view->loading(); }, .label = std::string{}}),
107 };
108 for (std::size_t const index : actionsOf(session.model(), CollectionActionScope::Collection)) {
109 CollectionAction const& action = elementAt(session.model().actions(), index);
110 ui::Button button{.label = action.label, .onClick = [view, index] { view->fire(index); }};
111 button.common.enabled = [view] { return !view->actionPending(); };
112 button.common.testId = "collectionAction_" + action.action;
113 children.push_back(ui::button(std::move(button)));
114 }
115 return ui::row(ui::Row{.children = std::move(children), .gap = 4});
116}
117
118/// @brief A row's built-in buttons: Open (with a row action) and each row action.
119/// @param session The session.
120/// @param rowKey The row's key text.
121/// @param options The view options.
122/// @return The buttons, in a row.
123[[nodiscard]] inline ui::Node rowButtons(CollectionSession& session, std::string const& rowKey,
124 CollectionViewOptions const& options) {
125 CollectionSession* const view = &session;
126 std::vector<ui::Node> buttons;
127 if (session.model().rowAction().has_value()) {
128 ui::Button open{.label = options.openLabel, .onClick = [view, rowKey] { view->open(rowKey); }};
129 open.common.enabled = [view] { return view->editor() != nullptr; };
130 open.common.testId = "rowOpen_" + rowKey;
131 buttons.push_back(ui::button(std::move(open)));
132 }
133 for (std::size_t const index : actionsOf(session.model(), CollectionActionScope::Row)) {
134 CollectionAction const& action = elementAt(session.model().actions(), index);
135 ui::Button button{.label = action.label, .onClick = [view, index, rowKey] { view->fire(index, rowKey); }};
136 button.common.enabled = [view] { return !view->actionPending(); };
137 button.common.testId = "rowAction_" + action.action + "_" + rowKey;
138 buttons.push_back(ui::button(std::move(button)));
139 }
140 return ui::row(ui::Row{.children = std::move(buttons), .gap = 4});
141}
142
143/// @brief The rows table's activation and selection: activating a row opens it when the view has a row action; in a
144/// master-detail view a row is selected while the editor is open on it, and selecting one opens it.
145/// @param session The session.
146/// @return The options, with the table's test id.
147[[nodiscard]] inline ui::TableOptions rowsTableOptions(CollectionSession& session) {
148 CollectionSession* const view = &session;
149 ui::TableOptions table{};
150 if (session.model().rowAction().has_value()) {
151 table.onActivate = [view](ui::Key const& key) {
152 if (auto const* const text = std::get_if<std::string>(&key)) {
153 view->open(*text);
154 }
155 };
156 }
157 if (session.model().kind() == CollectionKind::MasterDetail) {
158 table.selectionMode = ui::SelectionMode::Single;
159 table.selection = [view] {
160 std::optional<std::string> const& open = view->openRow();
161 return open.has_value() ? std::vector<ui::Key>{ui::Key{*open}} : std::vector<ui::Key>{};
162 };
163 table.onSelectionChange = [view](std::vector<ui::Key> const& keys) {
164 auto const* const text = keys.empty() ? nullptr : std::get_if<std::string>(&keys.front());
165 if (text == nullptr) {
166 view->close();
167 } else {
168 view->open(*text);
169 }
170 };
171 }
172 table.common.testId = "collection_rows";
173 return table;
174}
175
176/// @brief The rows: a table of the visible columns' cells and the row buttons, or a list of the row chrome's nodes.
177/// In a master-detail view a row is selected while the editor is open on it, and selecting one opens it.
178/// @param session The session.
179/// @param options The view options, shared with the row views.
180/// @return The rows.
181[[nodiscard]] inline ui::Node collectionRows(CollectionSession& session,
182 std::shared_ptr<CollectionViewOptions const> const& options) {
183 CollectionSession* const view = &session;
184 auto const rows = [view] { return view->rows(); };
185 auto const keyOf = [](CollectionRow const& row) { return ui::Key{row.key}; };
186 if (options->chrome.row) {
187 ui::Node list = ui::forEach<CollectionRow>(
188 rows, keyOf,
189 [view, options](reactive::Signal<CollectionRow> const& slot) { return options->chrome.row(*view, slot); });
190 return list;
191 }
192 std::vector<ui::TableColumn> columns;
193 std::vector<std::string> fields;
194 for (CollectionColumn const& column : session.model().columns()) {
195 if (!column.hidden) {
196 columns.push_back(ui::TableColumn{.label = column.label});
197 fields.push_back(column.field);
198 }
199 }
200 bool const buttons =
201 session.model().rowAction().has_value() || !actionsOf(session.model(), CollectionActionScope::Row).empty();
202 if (buttons) {
203 columns.push_back(ui::TableColumn{.label = std::string{}});
204 }
205 ui::TableOptions table = rowsTableOptions(session);
206 return ui::table<CollectionRow>(
207 std::move(columns), rows, keyOf,
208 [view, options, fields, buttons](reactive::Signal<CollectionRow> const& slot) {
209 reactive::Signal<CollectionRow> const* const row = &slot;
210 std::string const key = slot.peek().key;
211 std::vector<ui::Node> cells;
212 for (std::size_t i = 0; i < fields.size(); ++i) {
213 ui::Text cell{.text = [row, i] {
214 std::vector<std::string> const& texts = row->get().cells;
215 return i < texts.size() ? texts.at(i) : std::string{};
216 }};
217 cell.common.testId = "cell_" + fields.at(i) + "_" + key;
218 cells.push_back(ui::text(std::move(cell)));
219 }
220 if (buttons) {
221 cells.push_back(rowButtons(*view, key, *options));
222 }
223 return cells;
224 },
225 std::move(table));
226}
227
228/// @brief The built-in confirm dialog: open while an action waits, its question and the accept and cancel buttons.
229/// @param session The session.
230/// @param options The view options.
231/// @return The dialog.
232[[nodiscard]] inline ui::Node confirmDialog(CollectionSession& session, CollectionViewOptions const& options) {
233 CollectionSession* const view = &session;
234 ui::Button accept{.label = options.acceptLabel, .onClick = [view] { view->confirm(); }};
235 accept.common.testId = "confirm_accept";
236 ui::Button cancel{.label = options.cancelLabel, .onClick = [view] { view->cancelConfirm(); }};
237 cancel.common.testId = "confirm_cancel";
238 ui::Text message{.text = options.confirmMessage};
239 message.common.testId = "confirm_message";
240 ui::Dialog dialog{
241 .open = [view] { return view->pendingConfirm().has_value(); },
242 .title = options.confirmTitle,
243 .child = ui::column(ui::Column{
244 .children = {ui::text(std::move(message)),
245 ui::row(ui::Row{.children = {ui::button(std::move(accept)), ui::button(std::move(cancel))},
246 .gap = 4})},
247 .gap = 4}),
248 .onDismiss = [view] { view->cancelConfirm(); },
249 };
250 dialog.common.testId = "confirmDialog";
251 return ui::dialog(std::move(dialog));
252}
253
254/// @brief The confirm chrome, mounted only while an action waits.
255/// @param session The session.
256/// @param chrome The host's confirm chrome.
257/// @return A switch showing it while `pendingConfirm()` is engaged.
258[[nodiscard]] inline ui::Node confirmChrome(CollectionSession& session,
259 std::function<ui::Node(CollectionSession&)> const& chrome) {
260 CollectionSession const* const view = &session;
261 return ui::switchOf(ui::Switch{
262 .selector = [view] { return ui::Key{std::int64_t{view->pendingConfirm().has_value() ? 1 : 0}}; },
263 .cases = {ui::SwitchCase{.key = ui::Key{std::int64_t{1}}, .node = chrome(session)}},
264 });
265}
266
267} // namespace detail::engine
268
269/// @brief Renders @p session: the header (title, busy indicator, collection actions), the error line, the rows as a
270/// table (visible columns' exact cells, then Open and the row actions), the confirm dialog, and the row
271/// editor (`formView` of `editor()`) as a dialog for a `collection`, or beside the table, shown while a row
272/// is open, for a `master-detail` view.
273///
274/// Test ids: `collection_title`, `collectionAction_<Action>`, `collection_error`, `collection_rows`,
275/// `cell_<field>_<rowKey>`, `rowOpen_<rowKey>`, `rowAction_<Action>_<rowKey>`, `confirmDialog`, `confirm_message`,
276/// `confirm_accept`, `confirm_cancel`, `editorDialog` and `detailForm`; the editor's fields have their form ids.
277///
278/// The nodes bind to the session by pointer: mount the result after the session and destroy the mount before it.
279/// @param session The session.
280/// @param options The editor's form options, the chrome and the labels.
281/// @return The view.
282[[nodiscard]] inline ui::Node collectionView(CollectionSession& session, CollectionViewOptions options = {}) {
283 auto const shared = std::make_shared<CollectionViewOptions const>(std::move(options));
284 CollectionSession* const view = &session;
285 std::vector<ui::Node> list;
286 list.push_back(shared->chrome.header ? shared->chrome.header(session)
287 : detail::engine::collectionHeader(session, shared->title));
288 ui::Text error{.text = [view] { return detail::engine::collectionErrorText(*view); }, .role = ui::TextRole::Error};
289 error.common.visible = [view] { return view->listError() != nullptr || view->actionError() != nullptr; };
290 error.common.testId = "collection_error";
291 list.push_back(ui::text(std::move(error)));
292 list.push_back(detail::engine::collectionRows(session, shared));
293 list.push_back(shared->chrome.confirm ? detail::engine::confirmChrome(session, shared->chrome.confirm)
294 : detail::engine::confirmDialog(session, *shared));
295 FormSession* const editor = session.editor();
296 bool const masterDetail = session.model().kind() == CollectionKind::MasterDetail;
297 if (editor != nullptr && !masterDetail) {
298 ui::Node form = formView(*editor, shared->form);
299 if (shared->chrome.editor) {
300 list.push_back(shared->chrome.editor(session, std::move(form)));
301 } else {
302 ui::Dialog dialog{
303 .open = [view] { return view->openRow().has_value(); },
304 // NOLINTNEXTLINE(bugprone-unchecked-optional-access): an editor exists only for a row action.
305 .title = session.model().rowAction().value().action,
306 .child = std::move(form),
307 .onDismiss = [view] { view->close(); },
308 };
309 dialog.common.testId = "editorDialog";
310 list.push_back(ui::dialog(std::move(dialog)));
311 }
312 }
313 ui::Node column = ui::column(ui::Column{.children = std::move(list), .gap = 4});
314 if (editor == nullptr || !masterDetail) {
315 return column;
316 }
317 ui::Column detail{.children = {formView(*editor, shared->form)}};
318 detail.common.visible = [view] { return view->openRow().has_value(); };
319 detail.common.testId = "detailForm";
320 return ui::row(ui::Row{.children = {std::move(column), ui::column(std::move(detail))}, .gap = 5});
321}
322
323} // 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
Node button(ButtonOptions const &options)
A button node.
Definition author.hpp:3156
Handle< T > row()
The innermost row, {"row": ""}.
Definition author.hpp:1145
AnyHandle session(std::string const &name)
A session attribute, {"session": name}.
Definition author.hpp:1214
std::vector< TableColumn > cells(std::vector< Node > const &cells)
Columns from cells alone, without ids or labels.
Definition author.hpp:3686
Node dialog(Element const &element, Nodes const &children, DialogOptions const &options={})
A dialog node, a scope of its own; open and close name it by its element.
Definition author.hpp:3611
Command open(Element const &dialog)
open: opens a dialog or drawer.
Definition author.hpp:2779
Node table(AnyHandle const &rows, TableOptions const &options)
A table over a list, with the columns in its options.
Definition author.hpp:3806
Node form(ModelHandle< Model > const &model, FormOptions< Action > const &options={})
A form node: a schema form of the forms engine over an action (spec 2); each field takes its label fr...
Definition author.hpp:4271
The reactive state of one collection: the list, the table source, the actions, the confirm step and t...
The reactive state of one form: a controller over a FormModel.
A source value. Reading it with get() inside a tracked run subscribes that run.
Definition signal.hpp:49
A node property: a constant, or a slot — a reactive read, with a stable id when it was given one.
Definition view.hpp:106
Node testId(std::string const &name) const
A stable identifier for automated tests.
Definition author.hpp:1907
Node visible(AnyHandle const &condition) const
Whether the node shows.
Definition author.hpp:1823
Node enabled(AnyHandle const &condition) const
Whether the node is enabled.
Definition author.hpp:1828
Collections, headless: a v-* view document as a reactive list of rows, the table source the table eng...
@ MasterDetail
v-kind: master-detail: the editor sits beside the table.
ui::Node collectionView(CollectionSession &session, CollectionViewOptions options={})
Renders session: the header (title, busy indicator, collection actions), the error line,...
Declarative control: Query, Mutation and Subscription turn server interaction into reactive state,...
std::string errorMessage(std::exception_ptr const &error)
The one place an exception_ptr becomes display text.
Definition control.hpp:39
formView: a FormSession rendered as a view tree (ui::Node), so one form draws on every frontend.
ui::Node formView(FormSession &session, FormViewOptions options={})
Renders session: the title (the options' title, else the schema's while showTitle),...
Key keyOf(Value const &value)
A value as a view key: an int or a string as itself, anything else as its JSON text.
Signal<T>, Computed<T> and Effect: the nodes of a reactive graph.
Replacements for the parts of a collection view a host draws from its own UI kit. Each one left empty...
std::function< ui::Node(CollectionSession &, ui::Node editor)> editor
Replaces the editor dialog of a collection view; it is always mounted, and shows the editor while ope...
std::function< ui::Node(CollectionSession &)> confirm
Replaces the confirm dialog: mounted while an action waits for confirmation, unmounted once it is ans...
std::function< ui::Node(CollectionSession &)> header
Replaces the title and the collection actions' buttons. Called once, with the session,...
std::function< ui::Node(CollectionSession &, reactive::Signal< CollectionRow > const &)> row
Replaces each row's cells and buttons: the rows are then a list of these nodes rather than a table....
How collectionView draws a collection and labels its chrome.
std::string confirmTitle
The confirm dialog's title.
std::string confirmMessage
The confirm dialog's question.
std::string openLabel
The caption of a row's Open button.
std::string acceptLabel
The confirm dialog's accept button.
CollectionChrome chrome
The host's replacements for the view's own parts.
std::string cancelLabel
The confirm dialog's cancel button.
FormViewOptions form
The editor's form view options; its overrides reach the editor's fields.
ui::Prop< std::string > title
The built-in header's title (collection_title); empty shows the schema's.
How formView lays a form out and labels its chrome.
Definition form_view.hpp:38
Static or bound text.
Definition view.hpp:406
The toolkit-agnostic view tree: props, node kinds and their builders.
std::shared_ptr< NodeData const > Node
A view-tree node: immutable, shared data.
Definition view.hpp:403
SlotBinding< std::decay_t< F > > slot(SlotId slotId, F &&read)
A slot with an id.
Definition view.hpp:63