morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
form_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/form_view.hpp
6/// @brief `formView`: a `FormSession` rendered as a view tree (`ui::Node`), so one form draws on every frontend.
7///
8/// Specified in `docs/spec/forms/engine.md`, "Rendering".
9
10#include <algorithm>
11#include <charconv>
12#include <chrono>
13#include <cstddef>
14#include <cstdint>
15#include <exception>
16#include <memory>
17#include <optional>
18#include <stdexcept>
19#include <string>
20#include <string_view>
21#include <utility>
22#include <variant>
23#include <vector>
24
26#include "../../ui/view.hpp"
28#include "../layout.hpp"
29#include "detail/text.hpp"
30#include "field_layout.hpp"
31#include "field_model.hpp"
32#include "form_session.hpp"
33#include "overrides.hpp"
34
35namespace morph::forms {
36
37/// @brief How `formView` lays a form out and labels its chrome.
39 /// @brief Replacement controls.
41 /// @brief Columns of every section's, accordion's and tab's grid; `x-colspan` is clamped to it.
42 int gridColumns = 2;
43 /// @brief Columns of the implicit group's grid; unset is 1 while `gridColumns` is 2, else `gridColumns`.
44 std::optional<int> flatGridColumns;
45 /// @brief The explicit-mode button's label, which the status line names (`ready: press <label>`).
47 /// @brief The caption of a collection's Add button.
48 std::string addLabel = "Add";
49 /// @brief The caption of a collection row's Remove button.
50 std::string removeLabel = "Remove";
51 /// @brief Whether the form's title heads the view.
52 bool showTitle = true;
53 /// @brief Whether the status line ("fill the required (*) fields", …) is shown.
54 bool showStatus = true;
55 /// @brief A heading over the fields (test id `form_title`) in place of the schema's title; empty shows none.
56 /// A title given is shown whatever `showTitle` says.
58 /// @brief Where each cell puts its field's caption: over the control, or beside it.
60 /// @brief The width of an inline caption, in backend units; at least 1, else `formView` throws
61 /// `std::invalid_argument`. Read only for `FieldLayout::Inline`.
63};
64
65namespace detail::engine {
66
67/// @brief The shared view options a form's nodes hold.
68using ViewOptions = std::shared_ptr<FormViewOptions const>;
69
70/// @brief The status line under a form.
71/// @param session The session.
72/// @param submitLabel The explicit-mode button's label, which the line names.
73/// @return The line: what is missing, or how the form submits.
74// NOLINTNEXTLINE(modernize-use-string-view): the line is a bound text property, which holds a string.
75[[nodiscard]] inline std::string statusText(FormSession const& session, std::string_view submitLabel) {
76 if (!session.ready()) {
77 return "fill the required (*) fields";
78 }
79 return session.submitMode() == SubmitMode::Explicit ? "ready: press " + std::string{submitLabel}
80 : "submits as you type";
81}
82
83/// @brief Whether a text property says something: bound, or a constant that is not empty.
84/// @param text The property.
85/// @return Whether it is set.
86[[nodiscard]] inline bool isSet(ui::Prop<std::string> const& text) {
87 return text.isBound() || !text.constant().empty();
88}
89
90/// @brief The last reply or failure.
91/// @param session The session.
92/// @return `error: <message>`, `ok: <reply>`, or empty before the first answer.
93[[nodiscard]] inline std::string resultText(FormSession const& session) {
94 if (std::exception_ptr const error = session.lastError()) {
95 return "error: " + reactive::errorMessage(error);
96 }
97 std::optional<std::string> const& reply = session.lastReply();
98 return reply.has_value() ? "ok: " + *reply : std::string{};
99}
100
101/// @brief A draft wall clock (`YYYY-MM-DDTHH:MM[:SS]`, or a `YYYY-MM-DD` date) as the instant it denotes.
102/// @param text The draft.
103/// @param date Whether the field holds a date.
104/// @param offsetMinutes The display zone.
105/// @return The instant, or `std::nullopt` while the draft is not a complete one.
106[[nodiscard]] inline std::optional<time::Timestamp> draftInstant(std::string_view text, bool date, int offsetMinutes) {
107 std::optional<std::chrono::sys_seconds> local;
108 if (date) {
109 if (isIsoDate(text)) {
110 local = parseWallClock(std::string{text} + "T00:00");
111 }
112 } else {
113 local = parseWallClock(text);
114 }
115 if (!local) {
116 return std::nullopt;
117 }
118 auto const utc = *local - std::chrono::minutes{date ? 0 : offsetMinutes};
119 return time::Timestamp{time::DateTime{std::chrono::time_point_cast<std::chrono::milliseconds>(utc)}};
120}
121
122/// @brief The draft a date-time control writes for an instant (blank for none).
123/// @param value The instant.
124/// @param date Whether the field holds a date.
125/// @param offsetMinutes The display zone.
126/// @return `YYYY-MM-DDTHH:MM:SS` in the zone, or `YYYY-MM-DD`.
127[[nodiscard]] inline std::string instantDraft(std::optional<time::Timestamp> const& value, bool date,
128 int offsetMinutes) {
129 if (!value || !value->hasValue()) {
130 return {};
131 }
132 auto const instant =
133 std::chrono::floor<std::chrono::seconds>((**value).value) + std::chrono::minutes{date ? 0 : offsetMinutes};
134 std::string const text = formatWallClock(instant);
135 return date ? text.substr(0, 10) : text;
136}
137
138/// @brief The view-tree field state of a field: read-only, required, and its error as the one message shown.
139/// @param field The field.
140/// @return The state, bound to the field.
141[[nodiscard]] inline ui::FieldState fieldState(FieldState const* field) {
142 return ui::FieldState{
143 .readonly = [field] { return field->readonly().get(); },
144 .required = [field] { return field->required().get(); },
145 .errors =
146 [field] {
147 auto const& error = field->error().get();
148 return error.has_value() ? std::vector<std::string>{error->message} : std::vector<std::string>{};
149 },
150 .stale = false,
151 };
152}
153
154/// @brief The `Common` of a field's control: its test id (`field_<path>`) and accessible name (its label).
155/// @param field The field.
156/// @return The common part.
157[[nodiscard]] inline ui::Common controlCommon(FieldState const* field) {
158 ui::Common common{};
159 common.testId = "field_" + field->path();
160 common.a11y.name = field->label();
161 return common;
162}
163
164[[nodiscard]] ui::Node fieldCell(FormSession& session, FieldState& state, ViewOptions const& options);
165
166/// @brief A Select over the field's options (an Enum's, or a Choice's as last fetched), keyed by each option's
167/// JSON literal, so an id past 2^53 is never a number on the way.
168/// @param field The field.
169/// @param style Dropdown or radio.
170/// @return The control.
171[[nodiscard]] inline ui::Node optionSelect(FieldState* field, ui::SelectStyle style) {
172 return ui::select(ui::Select{
173 .options =
174 [field] {
175 std::vector<ui::SelectOption> options;
176 for (ChoiceOption const& option : field->options()) {
177 options.push_back(ui::SelectOption{.key = ui::Key{option.valueJson}, .label = option.label});
178 }
179 return options;
180 },
181 .selected = [field] -> std::optional<ui::Key> {
182 std::string_view const selected = trim(field->text().get());
183 return selected.empty() ? std::nullopt : std::optional<ui::Key>{ui::Key{std::string{selected}}};
184 },
185 .onSelect =
186 [field](ui::Key const& key) {
187 if (auto const* const value = std::get_if<std::string>(&key)) {
188 field->text().set(*value);
189 }
190 },
191 .onOpen = [field] { field->retryOptions(); },
192 .style = style,
193 // The cell draws the caption, so the select is told its label and not to show it.
194 .label = [field] { return field->label(); },
195 .showLabel = false,
196 .field = fieldState(field),
197 .common = controlCommon(field),
198 });
199}
200
201/// @brief The built-in control of one field (the table in `docs/spec/forms/engine.md`, "Rendering").
202/// @param session The session.
203/// @param state The field.
204/// @param options The view options (members and rows draw their own cells with them).
205/// @return The control.
206// One case per kind; an Object or an ObjectArray draws its members' cells.
207// NOLINTNEXTLINE(misc-no-recursion,readability-function-cognitive-complexity)
208[[nodiscard]] inline ui::Node fieldControl(FormSession& session, FieldState& state, ViewOptions const& options) {
209 FieldState* const field = &state;
210 FieldSpec const& spec = state.spec();
211 auto const textInput = [field](ui::TextInputMode mode, std::string unit = {}, int decimals = -1) {
212 return ui::textInput(ui::TextInput{
213 .value = [field] { return field->text().get(); },
214 .onChange = [field](std::string text) { field->text().set(std::move(text)); },
215 .onSubmit = {},
216 .onCommit = {},
217 .placeholder = field->placeholder(),
218 .mode = mode,
219 .unit = std::move(unit),
220 .decimals = decimals,
221 .label = [field] { return field->label(); },
222 .showLabel = false,
223 .field = fieldState(field),
224 .common = controlCommon(field),
225 });
226 };
227 // A number's input shows its unit and knows its entry places: an integer's 0, the entry unit's, a plain
228 // number's display places, a decimal's declared ones; -1 where none is declared.
229 auto const numberInput = [&] {
230 int decimals = -1;
231 if (spec.kind == FieldKind::Integer) {
232 decimals = 0;
233 } else if (!spec.units.empty()) {
234 decimals = static_cast<int>(spec.units.at(spec.entryUnit).decimals);
235 } else if (spec.displayDecimals) {
236 decimals = static_cast<int>(*spec.displayDecimals);
237 } else if (spec.decimalPlaces) {
238 decimals = static_cast<int>(*spec.decimalPlaces);
239 }
240 return textInput(ui::TextInputMode::SingleLine, spec.unit, decimals);
241 };
242 auto const dateInput = [&](bool date) {
243 int const offset = date ? 0 : session.options().displayOffsetMinutes;
244 return ui::dateTimeInput(ui::DateTimeInput{
245 .value = [field, date, offset] { return draftInstant(trim(field->text().get()), date, offset); },
246 .onChange =
247 [field, date, offset](std::optional<time::Timestamp> const& value) {
248 field->text().set(instantDraft(value, date, offset));
249 },
250 .mode = date ? ui::DateMode::Date : ui::DateMode::DateTime,
251 .offsetMinutes = offset,
252 .label = [field] { return field->label(); },
253 .showLabel = false,
254 .field = fieldState(field),
255 .common = controlCommon(field),
256 });
257 };
258 if (spec.kind == FieldKind::Multiline) {
259 return textInput(ui::TextInputMode::Multiline);
260 }
261 if (spec.kind == FieldKind::Integer || spec.kind == FieldKind::Number || spec.kind == FieldKind::Decimal) {
262 return numberInput();
263 }
264 if (spec.kind == FieldKind::Quantity) {
265 if (spec.units.size() <= 1) {
266 return numberInput();
267 }
268 std::vector<ui::SelectOption> units;
269 units.reserve(spec.units.size());
270 for (std::size_t i = 0; i < spec.units.size(); ++i) {
271 units.push_back(
272 ui::SelectOption{.key = ui::Key{static_cast<std::int64_t>(i)}, .label = spec.units.at(i).display});
273 }
274 ui::Common unitCommon{};
275 unitCommon.testId = "unit_" + field->path();
276 unitCommon.a11y.name = field->label();
277 return ui::row(ui::Row{
278 .children = {textInput(ui::TextInputMode::SingleLine),
279 ui::select(ui::Select{
280 .options = std::move(units),
281 .selected = [field] -> std::optional<ui::Key> {
282 return ui::Key{static_cast<std::int64_t>(field->unit().get())};
283 },
284 .onSelect =
285 [field](ui::Key const& key) {
286 if (auto const* const index = std::get_if<std::int64_t>(&key);
287 index != nullptr && *index >= 0) {
288 field->switchUnit(static_cast<std::size_t>(*index));
289 }
290 },
291 .style = ui::SelectStyle::Dropdown,
292 .field = {},
293 .common = std::move(unitCommon),
294 })},
295 .gap = 4,
296 });
297 }
298 if (spec.kind == FieldKind::Boolean) {
299 return ui::checkbox(ui::Checkbox{
300 .label = field->label(),
301 .checked = [field] { return trim(field->text().get()) == "true"; },
302 .onToggle = [field](bool checked) { field->text().set(checked ? "true" : "false"); },
303 .field = fieldState(field),
304 .common = controlCommon(field),
305 });
306 }
307 if (spec.kind == FieldKind::Enum) {
308 return optionSelect(field, spec.widget == "radio" ? ui::SelectStyle::Radio : ui::SelectStyle::Dropdown);
309 }
310 if (spec.kind == FieldKind::Choice) {
311 return ui::row(ui::Row{
312 .children =
313 {optionSelect(field, spec.widget == "radio" ? ui::SelectStyle::Radio : ui::SelectStyle::Dropdown),
314 ui::busy(ui::Busy{.active = [field] { return field->optionsPending(); }, .label = std::string{}})},
315 .gap = 4,
316 });
317 }
318 if (spec.kind == FieldKind::DateTime || spec.kind == FieldKind::EpochDateTime) {
319 return dateInput(false);
320 }
321 if (spec.kind == FieldKind::Date || spec.kind == FieldKind::EpochDate) {
322 return dateInput(true);
323 }
324 if (spec.kind == FieldKind::Slider) {
325 return ui::slider(ui::Slider{
326 .value =
327 [field] {
328 std::string_view const text = trim(field->text().get());
329 std::int64_t value = field->spec().sliderMin;
330 std::from_chars(std::to_address(text.begin()), std::to_address(text.end()), value);
331 return value;
332 },
333 .minimum = spec.sliderMin,
334 .maximum = spec.sliderMax,
335 .step = spec.sliderStep,
336 .onChange = [field](std::int64_t value) { field->text().set(std::to_string(value)); },
337 .field = fieldState(field),
338 .common = controlCommon(field),
339 });
340 }
341 if ((spec.kind == FieldKind::Object || spec.kind == FieldKind::ObjectArray) && spec.truncated) {
342 return ui::text(ui::Text{.text = "nested too deeply to edit here", .role = ui::TextRole::Muted});
343 }
344 if (spec.kind == FieldKind::Object) {
345 std::vector<ui::Node> cells;
346 for (auto const& member : state.members()) {
347 cells.push_back(fieldCell(session, *member, options));
348 }
349 ui::Panel panel{
350 .title = state.label(), .padding = 4, .child = ui::column(ui::Column{.children = std::move(cells)})};
351 panel.common.testId = "field_" + state.path();
352 return ui::panel(std::move(panel));
353 }
354 if (spec.kind == FieldKind::ObjectArray) {
355 FormSession* const owner = &session;
356 ViewOptions const& shared = options;
357 auto rows = ui::forEach<std::shared_ptr<RowState>>(
358 [field] { return field->rows(); },
359 [](std::shared_ptr<RowState> const& row) { return ui::Key{static_cast<std::int64_t>(row->key())}; },
360 [field, owner, shared](reactive::Signal<std::shared_ptr<RowState>> const& slot) {
361 std::shared_ptr<RowState> const& row = slot.peek();
362 std::vector<ui::Node> cells;
363 for (auto const& member : row->fields()) {
364 cells.push_back(fieldCell(*owner, *member, shared));
365 }
366 std::uint64_t const key = row->key();
367 ui::Button remove{.label = shared->removeLabel, .onClick = [field, key] { field->removeRow(key); }};
368 remove.common.testId = "remove_" + field->path() + "#" + std::to_string(key);
369 cells.push_back(ui::button(std::move(remove)));
370 // The title binding holds the row: it is the row view's first binding, so it is destroyed after
371 // every binding inside the panel, and the row's state outlives everything that reads it after the
372 // session has let it go.
373 return ui::panel(ui::Panel{
374 .title =
375 [field, row] {
376 auto const& current = field->rows();
377 auto const found = std::ranges::find(current, row);
378 return field->label() + " " + std::to_string(found - current.begin() + 1);
379 },
380 .padding = 4,
381 .child = ui::column(ui::Column{.children = std::move(cells)}),
382 });
383 });
384 ui::Button add{.label = options->addLabel, .onClick = [field] { field->addRow(); }};
385 add.common.testId = "add_" + field->path();
386 ui::Column column{.children = {std::move(rows), ui::button(std::move(add))}};
387 column.common.testId = "field_" + state.path();
388 return ui::column(std::move(column));
389 }
390 return textInput(ui::TextInputMode::SingleLine);
391}
392
393/// @brief Space as wide as an inline caption, which lines a Boolean's box or a help text up with the controls.
394/// @param width The caption's width, in backend units.
395/// @return The spacer.
396[[nodiscard]] inline ui::Node captionSpace(int width) {
397 ui::Spacer space{};
398 space.common.layout = {.width = ui::Sizing::fixed(width), .height = {}};
399 return ui::spacer(std::move(space));
400}
401
402/// @brief The inline form of a cell's content: a row centred across, the label at the caption's width (a spacer for
403/// a Boolean, whose checkbox carries its label) and the control stretching; the help is a second row under the
404/// control, indented by the caption's width.
405/// @param state The field.
406/// @param control The control.
407/// @param options The view options.
408/// @param common The cell's own visibility and test id.
409/// @return The row, or a column of the row and the help's row.
410[[nodiscard]] inline ui::Node inlineCell(FieldState const& state, ui::Node control, ViewOptions const& options,
411 ui::Common common) {
412 FieldState const* const field = &state;
413 int const width = options->inlineLabelWidth;
414 ui::Node caption;
415 if (state.spec().kind == FieldKind::Boolean) {
416 caption = captionSpace(width);
417 } else {
418 ui::Text label{.text = [field] { return field->label(); }};
419 label.common.testId = "label_" + state.path();
420 label.common.layout = {.width = ui::Sizing::fixed(width), .height = {}};
421 caption = ui::text(std::move(label));
422 }
423 // A column fills its one content child across, so the control takes the width the stretching column is given.
424 ui::Column holder{.children = {std::move(control)}};
425 holder.common.layout = {.width = ui::Sizing::stretch(), .height = {}};
426 ui::Row line{
427 .children = {std::move(caption), ui::column(std::move(holder))}, .gap = 3, .align = ui::Align::Center};
428 if (state.help().empty()) {
429 line.common = std::move(common);
430 return ui::row(std::move(line));
431 }
432 ui::Row help{
433 .children = {captionSpace(width), ui::text(ui::Text{.text = state.help(), .role = ui::TextRole::Muted})},
434 .gap = 3};
435 ui::Column cell{.children = {ui::row(std::move(line)), ui::row(std::move(help))}};
436 cell.common = std::move(common);
437 return ui::column(std::move(cell));
438}
439
440/// @brief One field's cell: the label, the control (an override's, else the built-in) and the help text; shown while
441/// the field is visible. The control marks a required field, so the label carries no mark of its own. The
442/// options' `fieldLayout` puts the label over the control, or beside it (`inlineCell`); an Object's panel and
443/// an ObjectArray's rows are stacked either way, and their members follow the form's layout.
444/// @param session The session.
445/// @param state The field.
446/// @param options The view options.
447/// @return The cell.
448// NOLINTNEXTLINE(misc-no-recursion): nested controls draw cells.
449inline ui::Node fieldCell(FormSession& session, FieldState& state, ViewOptions const& options) {
450 FieldState const* const field = &state;
451 ui::Node control;
452 if (Overrides::Render const* const render = options->overrides.resolve(state.spec())) {
453 control = (*render)(FieldView{session, state, options});
454 } else {
455 control = fieldControl(session, state, options);
456 }
457 ui::Common common{};
458 common.visible = [field] { return field->visible().get(); };
459 common.testId = "cell_" + state.path();
460 FieldKind const kind = state.spec().kind;
461 if (options->fieldLayout == FieldLayout::Inline && kind != FieldKind::Object && kind != FieldKind::ObjectArray) {
462 return inlineCell(state, std::move(control), options, std::move(common));
463 }
464 std::vector<ui::Node> children;
465 if (state.spec().kind != FieldKind::Boolean && state.spec().kind != FieldKind::Object) {
466 ui::Text label{.text = [field] { return field->label(); }};
467 label.common.testId = "label_" + state.path();
468 children.push_back(ui::text(std::move(label)));
469 }
470 children.push_back(std::move(control));
471 if (!state.help().empty()) {
472 children.push_back(ui::text(ui::Text{.text = state.help(), .role = ui::TextRole::Muted}));
473 }
474 ui::Column cell{.children = std::move(children), .common = std::move(common)};
475 return ui::column(std::move(cell));
476}
477
478/// @brief The fields of one group on a grid of @p columns, each spanning `x-colspan` clamped to it.
479/// @param session The session.
480/// @param group The group.
481/// @param columns The grid's columns.
482/// @param options The view options.
483/// @return The grid.
484[[nodiscard]] inline ui::Node groupGrid(FormSession& session, FieldGroupSpec const& group, int columns,
485 ViewOptions const& options) {
486 std::vector<ui::GridCell> cells;
487 int const width = std::max(1, columns);
488 for (std::string const& name : group.fields) {
489 FieldState& state = session.field(name);
490 cells.push_back(ui::GridCell{.node = fieldCell(session, state, options),
491 .span = std::clamp(state.spec().colspan, 1, width)});
492 }
493 return ui::grid(ui::Grid{.columns = width, .cells = std::move(cells), .gap = 4});
494}
495
496} // namespace detail::engine
497
499 return detail::engine::fieldControl(*_session, *_state, _options);
500}
501
502/// @brief Renders @p session: the title (the options' `title`, else the schema's while `showTitle`), its fields laid
503/// out by group (sections as panels, consecutive tab groups as one tab bar, accordions as collapsible panels,
504/// the implicit group as a grid), the status line, the Submit button in explicit mode, and the last reply or
505/// error.
506///
507/// The nodes bind to the session and its fields by pointer: mount the result after the session and destroy the
508/// mount before it.
509/// @param session The session.
510/// @param options Overrides, grid columns, the chrome's labels, the field layout.
511/// @return The view.
512/// @throws std::invalid_argument when `options.inlineLabelWidth` is below 1.
513[[nodiscard]] inline ui::Node formView(FormSession& session, FormViewOptions options = {}) {
514 using detail::engine::groupGrid;
515 if (options.inlineLabelWidth < 1) {
516 throw std::invalid_argument{"morph::forms::formView: an inline label is at least one unit wide"};
517 }
518 auto const shared = std::make_shared<FormViewOptions const>(std::move(options));
519 int const columns = std::max(1, shared->gridColumns);
520 int const flatColumns = shared->flatGridColumns.value_or(columns == 2 ? 1 : columns);
521 FormSession* const form = &session;
522 std::vector<ui::Node> children;
523 if (detail::engine::isSet(shared->title)) {
524 ui::Text title{.text = shared->title, .role = ui::TextRole::Heading};
525 title.common.testId = "form_title";
526 children.push_back(ui::text(std::move(title)));
527 } else if (shared->showTitle) {
528 children.push_back(
529 ui::text(ui::Text{.text = std::string{session.model().title()}, .role = ui::TextRole::Heading}));
530 }
531 auto const groups = session.model().groups();
532 for (std::size_t i = 0; i < groups.size();) {
533 FieldGroupSpec const& group = detail::engine::elementAt(groups, i);
534 if (group.implicit) {
535 children.push_back(groupGrid(session, group, flatColumns, shared));
536 ++i;
537 } else if (group.kind == GroupKind::Tab) {
538 std::size_t const first = i;
539 std::vector<ui::Tab> tabs;
540 while (i < groups.size() && detail::engine::elementAt(groups, i).kind == GroupKind::Tab &&
541 !detail::engine::elementAt(groups, i).implicit) {
542 tabs.push_back(
543 ui::Tab{.label = session.groupTitle(i),
544 .node = groupGrid(session, detail::engine::elementAt(groups, i), columns, shared)});
545 ++i;
546 }
547 reactive::Signal<std::size_t>* const selected = &session.tabSelection(first);
548 children.push_back(
549 ui::tabs(ui::Tabs{.tabs = std::move(tabs),
550 .selected = [selected] { return selected->get(); },
551 .onSelect = [selected](std::size_t index) { selected->set(index); }}));
552 } else if (group.kind == GroupKind::Accordion) {
553 reactive::Signal<bool>* const collapsed = &session.groupCollapsed(i);
554 children.push_back(ui::panel(ui::Panel{.title = session.groupTitle(i),
555 .padding = 4,
556 .child = groupGrid(session, group, columns, shared),
557 .collapsible = true,
558 .collapsed = [collapsed] { return collapsed->get(); },
559 .onToggle = [collapsed](bool value) { collapsed->set(value); }}));
560 ++i;
561 } else {
562 children.push_back(ui::panel(ui::Panel{
563 .title = session.groupTitle(i), .padding = 4, .child = groupGrid(session, group, columns, shared)}));
564 ++i;
565 }
566 }
567 if (shared->showStatus) {
568 ui::Text status{
569 .text = [form, shared] { return detail::engine::statusText(*form, shared->submitLabel.evaluate()); },
570 .role = ui::TextRole::Muted};
571 status.common.testId = "form_status";
572 children.push_back(ui::text(std::move(status)));
573 }
574 if (session.submitMode() == SubmitMode::Explicit) {
575 ui::Button submit{.label = shared->submitLabel, .onClick = [form] { form->submit(); }};
576 submit.common.enabled = [form] { return form->ready() && !form->pending(); };
577 submit.common.testId = "form_submit";
578 children.push_back(ui::button(std::move(submit)));
579 }
580 ui::Text result{
581 .text = [form] { return detail::engine::resultText(*form); },
582 .role = [form] { return form->lastError() != nullptr ? ui::TextRole::Error : ui::TextRole::Success; },
583 };
584 result.common.visible = [form] { return form->lastError() != nullptr || form->lastReply().has_value(); };
585 result.common.testId = "form_result";
586 children.push_back(ui::text(std::move(result)));
587 return ui::column(ui::Column{.children = std::move(children), .gap = 4});
588}
589
590} // namespace morph::forms
Node text(TextOptions const &options)
A text node.
Definition author.hpp:3126
Node panel(Nodes const &children, PanelOptions const &options={})
A panel node.
Definition author.hpp:3446
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
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 textInput(TextInputOptions const &options)
A textInput node.
Definition author.hpp:3210
Node tabs(std::vector< Tab > const &tabs)
A tabs node.
Definition author.hpp:3536
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
ui::Node defaultControl() const
The control formView draws for this field without overrides, for an override to wrap.
The reactive state of one form: a controller over a FormModel.
Replacement controls, resolved per field in the order field path, widget hint, unit,...
Definition overrides.hpp:62
std::function< ui::Node(FieldView const &)> Render
Draws a field's control.
Definition overrides.hpp:65
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 a11y(AnyHandle const &name, std::string const &role={}) const
The accessible name and role.
Definition author.hpp:1918
Declarative control: Query, Mutation and Subscription turn server interaction into reactive state,...
UTC timestamps for morph actions.
morph::forms::FieldLayout: where a form's cell puts a field's caption. The forms engine and the autho...
FieldLayout
Where a form's cell puts a field's caption, relative to its control.
@ Stacked
The label over the control over the help.
FormModel: the immutable runtime model of one action's form, read from the schema JSON schemaJson<A>(...
FieldKind
The control a field gets, which the JSON type alone does not say.
@ Explicit
Only on FormSession::submit() (x-submitMode: explicit).
FormSession: the reactive state of one form over a FormModel — drafts, encoded values,...
ui::Node formView(FormSession &session, FormViewOptions options={})
Renders session: the title (the options' title, else the schema's while showTitle),...
Visual structure (sections, tabs, accordions, column spans) layered over an action's flat field list.
@ Accordion
A collapsible panel.
Definition layout.hpp:35
@ Tab
A pane of a shared tab bar; consecutive Tab groups share one bar.
Definition layout.hpp:34
Overrides: an application's replacements for the control formView draws for a field,...
One x-layout group, or the implicit group of the fields no group names.
How formView lays a form out and labels its chrome.
Definition form_view.hpp:38
ui::Prop< std::string > title
A heading over the fields (test id form_title) in place of the schema's title; empty shows none....
Definition form_view.hpp:57
ui::Prop< std::string > submitLabel
The explicit-mode button's label, which the status line names (ready: press <label>).
Definition form_view.hpp:46
Overrides overrides
Replacement controls.
Definition form_view.hpp:40
bool showTitle
Whether the form's title heads the view.
Definition form_view.hpp:52
std::optional< int > flatGridColumns
Columns of the implicit group's grid; unset is 1 while gridColumns is 2, else gridColumns.
Definition form_view.hpp:44
int inlineLabelWidth
The width of an inline caption, in backend units; at least 1, else formView throws std::invalid_argum...
Definition form_view.hpp:62
std::string removeLabel
The caption of a collection row's Remove button.
Definition form_view.hpp:50
std::string addLabel
The caption of a collection's Add button.
Definition form_view.hpp:48
bool showStatus
Whether the status line ("fill the required (*) fields", …) is shown.
Definition form_view.hpp:54
FieldLayout fieldLayout
Where each cell puts its field's caption: over the control, or beside it.
Definition form_view.hpp:59
int gridColumns
Columns of every section's, accordion's and tab's grid; x-colspan is clamped to it.
Definition form_view.hpp:42
static constexpr Sizing stretch(int weight=1) noexcept
A share of the leftover space.
Definition view.hpp:294
static constexpr Sizing fixed(int units) noexcept
A fixed size.
Definition view.hpp:289
Exact text helpers the forms engine shares: JSON quoting and reading, canonical decimal comparison an...
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