35namespace morph::forms {
65namespace detail::engine {
68using ViewOptions = std::shared_ptr<FormViewOptions const>;
75[[nodiscard]]
inline std::string statusText(
FormSession const& session, std::string_view submitLabel) {
76 if (!session.ready()) {
77 return "fill the required (*) fields";
80 :
"submits as you type";
87 return text.isBound() || !text.constant().empty();
93[[nodiscard]]
inline std::string resultText(FormSession
const& session) {
94 if (std::exception_ptr
const error = session.lastError()) {
95 return "error: " + reactive::errorMessage(error);
97 std::optional<std::string>
const& reply =
session.lastReply();
98 return reply.has_value() ?
"ok: " + *reply : std::string{};
106[[nodiscard]]
inline std::optional<time::Timestamp> draftInstant(std::string_view text,
bool date,
int offsetMinutes) {
107 std::optional<std::chrono::sys_seconds> local;
109 if (isIsoDate(text)) {
110 local = parseWallClock(std::string{
text} +
"T00:00");
113 local = parseWallClock(text);
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)}};
127[[nodiscard]]
inline std::string instantDraft(std::optional<time::Timestamp>
const& value,
bool date,
129 if (!value || !value->hasValue()) {
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;
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(); },
147 auto const& error = field->error().get();
148 return error.has_value() ? std::vector<std::string>{error->message} : std::vector<std::string>{};
157[[nodiscard]]
inline ui::Common controlCommon(FieldState
const* field) {
159 common.
testId =
"field_" + field->path();
160 common.
a11y.name = field->label();
164[[nodiscard]] ui::Node fieldCell(FormSession& session, FieldState& state, ViewOptions
const& options);
171[[nodiscard]]
inline ui::Node optionSelect(FieldState* field, ui::SelectStyle style) {
172 return ui::select(ui::Select{
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});
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}}};
186 [field](ui::Key
const& key) {
187 if (
auto const*
const value = std::get_if<std::string>(&key)) {
188 field->text().set(*value);
191 .onOpen = [field] { field->retryOptions(); },
194 .label = [field] {
return field->label(); },
196 .field = fieldState(field),
197 .common = controlCommon(field),
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)); },
217 .placeholder = field->placeholder(),
219 .unit = std::move(unit),
220 .decimals = decimals,
221 .label = [field] {
return field->label(); },
223 .field = fieldState(field),
224 .common = controlCommon(field),
229 auto const numberInput = [&] {
231 if (spec.kind == FieldKind::Integer) {
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);
240 return textInput(ui::TextInputMode::SingleLine, spec.unit, decimals);
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); },
247 [field, date, offset](std::optional<time::Timestamp>
const& value) {
248 field->text().set(instantDraft(value, date, offset));
250 .mode = date ? ui::DateMode::Date : ui::DateMode::DateTime,
251 .offsetMinutes = offset,
252 .label = [field] {
return field->label(); },
254 .field = fieldState(field),
255 .common = controlCommon(field),
258 if (spec.kind == FieldKind::Multiline) {
259 return textInput(ui::TextInputMode::Multiline);
261 if (spec.kind == FieldKind::Integer || spec.kind == FieldKind::Number || spec.kind == FieldKind::Decimal) {
262 return numberInput();
264 if (spec.kind == FieldKind::Quantity) {
265 if (spec.units.size() <= 1) {
266 return numberInput();
268 std::vector<ui::SelectOption> units;
269 units.reserve(spec.units.size());
270 for (std::size_t i = 0; i < spec.units.size(); ++i) {
272 ui::SelectOption{.key = ui::Key{
static_cast<std::int64_t
>(i)}, .label = spec.units.at(i).display});
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())};
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));
291 .style = ui::SelectStyle::Dropdown,
293 .common = std::move(unitCommon),
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),
307 if (spec.kind == FieldKind::Enum) {
308 return optionSelect(field, spec.widget ==
"radio" ? ui::SelectStyle::Radio : ui::SelectStyle::Dropdown);
310 if (spec.kind == FieldKind::Choice) {
311 return ui::row(ui::Row{
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{}})},
318 if (spec.kind == FieldKind::DateTime || spec.kind == FieldKind::EpochDateTime) {
319 return dateInput(
false);
321 if (spec.kind == FieldKind::Date || spec.kind == FieldKind::EpochDate) {
322 return dateInput(
true);
324 if (spec.kind == FieldKind::Slider) {
325 return ui::slider(ui::Slider{
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);
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),
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});
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));
350 .title = state.label(), .padding = 4, .child = ui::column(ui::Column{.children = std::move(cells)})};
352 return ui::panel(std::move(panel));
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));
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)));
373 return ui::panel(ui::Panel{
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);
381 .child = ui::column(ui::Column{.children = std::move(cells)}),
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))}};
388 return ui::column(std::move(column));
390 return textInput(ui::TextInputMode::SingleLine);
396[[nodiscard]]
inline ui::Node captionSpace(
int width) {
399 return ui::spacer(std::move(space));
410[[nodiscard]]
inline ui::Node inlineCell(FieldState
const& state, ui::Node control, ViewOptions
const& options,
412 FieldState
const*
const field = &state;
413 int const width = options->inlineLabelWidth;
415 if (state.spec().kind == FieldKind::Boolean) {
416 caption = captionSpace(width);
418 ui::Text label{.text = [field] {
return field->label(); }};
419 label.common.testId =
"label_" + state.path();
421 caption = ui::text(std::move(label));
424 ui::Column holder{.children = {std::move(control)}};
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));
433 .children = {captionSpace(width), ui::text(ui::Text{.text = state.help(), .role = ui::TextRole::Muted})},
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));
449inline ui::Node fieldCell(FormSession& session, FieldState& state, ViewOptions
const& options) {
450 FieldState
const*
const field = &state;
452 if (
Overrides::Render const*
const render = options->overrides.resolve(state.spec())) {
453 control = (*render)(FieldView{
session, state, options});
455 control = fieldControl(session, state, options);
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));
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)));
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}));
474 ui::Column cell{.children = std::move(children), .common = std::move(common)};
475 return ui::column(std::move(cell));
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)});
493 return ui::grid(ui::Grid{.columns = width, .cells = std::move(cells), .gap = 4});
499 return detail::engine::fieldControl(*_session, *_state, _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"};
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);
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) {
529 ui::text(ui::Text{.text = std::string{
session.model().title()}, .role = ui::TextRole::Heading}));
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));
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) {
543 ui::Tab{.label =
session.groupTitle(i),
544 .node = groupGrid(session, detail::engine::elementAt(groups, i), columns, shared)});
547 reactive::Signal<std::size_t>*
const selected = &
session.tabSelection(first);
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); }}));
553 reactive::Signal<bool>*
const collapsed = &
session.groupCollapsed(i);
554 children.push_back(ui::panel(ui::Panel{.title =
session.groupTitle(i),
556 .child = groupGrid(session, group, columns, shared),
558 .collapsed = [collapsed] {
return collapsed->get(); },
559 .onToggle = [collapsed](
bool value) { collapsed->set(value); }}));
562 children.push_back(ui::panel(ui::Panel{
563 .title =
session.groupTitle(i), .padding = 4, .child = groupGrid(session, group, columns, shared)}));
567 if (shared->showStatus) {
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)));
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)));
581 .text = [
form] {
return detail::engine::resultText(*form); },
582 .role = [
form] {
return form->lastError() !=
nullptr ? ui::TextRole::Error : ui::TextRole::Success; },
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});
Node text(TextOptions const &options)
A text node.
Node panel(Nodes const &children, PanelOptions const &options={})
A panel node.
Node column(Nodes const &children, ContainerOptions const &options={})
A column node.
Handle< T > row()
The innermost row, {"row": ""}.
AnyHandle session(std::string const &name)
A session attribute, {"session": name}.
std::vector< TableColumn > cells(std::vector< Node > const &cells)
Columns from cells alone, without ids or labels.
Node textInput(TextInputOptions const &options)
A textInput node.
Node tabs(std::vector< Tab > const &tabs)
A tabs node.
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...
A node property: a constant, or a slot — a reactive read, with a stable id when it was given one.
Node testId(std::string const &name) const
A stable identifier for automated tests.
Node a11y(AnyHandle const &name, std::string const &role={}) const
The accessible name and role.
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).
Visual structure (sections, tabs, accordions, column spans) layered over an action's flat field list.
@ Accordion
A collapsible panel.
@ Tab
A pane of a shared tab bar; consecutive Tab groups share one bar.
Overrides: an application's replacements for the control formView draws for a field,...
static constexpr Sizing stretch(int weight=1) noexcept
A share of the leftover space.
static constexpr Sizing fixed(int units) noexcept
A fixed size.
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.
SlotBinding< std::decay_t< F > > slot(SlotId slotId, F &&read)
A slot with an id.