morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
form_session.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_session.hpp
6/// @brief `FormSession`: the reactive state of one form over a `FormModel` — drafts, encoded
7/// values, readiness, presentation rules, Choice options and submission. A controller in
8/// the sense of `docs/spec/reactive/control.md`: no toolkit, testable headless.
9///
10/// Specified in `docs/spec/forms/engine.md`, "FormSession".
11
12#include <algorithm>
13#include <charconv>
14#include <cstddef>
15#include <cstdint>
16#include <exception>
17#include <expected>
18#include <functional>
19#include <memory>
20#include <optional>
21#include <span>
22#include <stdexcept>
23#include <string>
24#include <string_view>
25#include <system_error>
26#include <utility>
27#include <vector>
28
29#include "../../attributes.hpp"
30#include "../../core/completion.hpp"
34#include "../../render/i18n.hpp"
36#include "../i18n.hpp"
37#include "detail/text.hpp"
38#include "field_model.hpp"
39#include "rules.hpp"
40#include "values.hpp"
41
42namespace morph::forms {
43
44/// @brief Executes a form's action: the action type and its JSON body in, the JSON reply out.
45using Submitter = std::function<async::Completion<std::string>(std::string_view actionType, std::string bodyJson)>;
46
47/// @brief Executes a Choice's options action: the action and its JSON request in, the reply out.
49 std::function<async::Completion<std::string>(std::string_view optionsAction, std::string bodyJson)>;
50
51/// @brief How a session reads numbers, dates and text.
53 /// @brief How numbers are typed and shown. It holds views: the strings it names must outlive the session.
55 /// @brief The locale translations are resolved in.
56 std::string bcp47 = "en";
57 /// @brief The host's catalog; empty means every label is the schema's literal.
59 /// @brief The display zone for date-times, in minutes east of UTC.
61 /// @brief An extra readiness gate over the assembled body; empty means none. `Form<A>` puts
62 /// the action's own `validate()` here.
63 std::function<bool(std::string_view bodyJson)> accepts;
64};
65
66class FieldState;
67class RowState;
68
69namespace detail::engine {
70
71/// @brief What every field of one session shares: the runtime, the model, the options, the
72/// Choice fetcher, and the hook a programmatic change ends with.
73class SessionCore {
74public:
75 /// @param runtime The runtime. Borrowed.
76 /// @param model The form model.
77 /// @param choices The Choice fetcher (may be empty when no field is a Choice).
78 /// @param options The session options.
79 SessionCore(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, FormModel model, ChoiceFetcher choices,
80 FormSessionOptions options)
81 : _rt{&runtime},
82 _model{std::move(model)},
83 _choices{std::move(choices)},
84 _options{std::move(options)},
85 _values{.locale = _options.locale,
86 .displayOffsetMinutes = _options.displayOffsetMinutes,
87 .translations = _options.translations,
88 .bcp47 = _options.bcp47} {}
89
90 /// @brief The runtime.
91 /// @return The runtime passed at construction.
92 [[nodiscard]] reactive::Runtime& runtime() const noexcept { return *_rt; }
93
94 /// @brief The form model.
95 /// @return The model.
96 [[nodiscard]] FormModel const& model() const noexcept { return _model; }
97
98 /// @brief The session options.
99 /// @return The options.
100 [[nodiscard]] FormSessionOptions const& options() const noexcept { return _options; }
101
102 /// @brief The options a value conversion reads.
103 /// @return The value context.
104 [[nodiscard]] ValueContext const& values() const noexcept { return _values; }
105
106 /// @brief The Choice fetcher.
107 /// @return The fetcher; may be empty.
108 [[nodiscard]] ChoiceFetcher const& choices() const noexcept { return _choices; }
109
110 /// @brief Installs what `markProgrammatic` runs (the session records its current body as seen).
111 /// @param hook The hook.
112 void onProgrammatic(std::function<void()> hook) { _programmatic = std::move(hook); }
113
114 /// @brief Ends a programmatic change: automatic submission does not submit the body it produced.
115 void markProgrammatic() const {
116 if (_programmatic) {
117 _programmatic();
118 }
119 }
120
121 /// @brief Installs the top-level fields rules look up.
122 /// @param fields The session's fields. Borrowed: they must outlive this core's use.
123 void adopt(std::vector<std::unique_ptr<FieldState>> const* fields) noexcept { _fields = fields; }
124
125 /// @brief A top-level field by wire name.
126 /// @param name The wire name.
127 /// @return The field, or null.
128 [[nodiscard]] FieldState* topLevel(std::string_view name) const;
129
130 /// @brief The tracked lookup rules evaluate over.
131 /// @return A lookup reading each named field's encoding.
132 [[nodiscard]] RuleLookup lookup() const;
133
134private:
135 reactive::Runtime* _rt;
136 FormModel _model;
137 ChoiceFetcher _choices;
138 FormSessionOptions _options;
139 ValueContext _values;
140 std::function<void()> _programmatic;
141 std::vector<std::unique_ptr<FieldState>> const* _fields = nullptr;
142};
143
144} // namespace detail::engine
145
146/// @brief One collection row: the element's member fields.
147class RowState {
148public:
149 /// @param core The session core. Borrowed.
150 /// @param arraySpec The collection field's spec (its `children` are the members).
151 /// @param arrayPath The collection field's path.
152 /// @param key The row's stable key.
153 /// @param parent The collection field.
154 RowState(detail::engine::SessionCore& core, FieldSpec const& arraySpec, std::string const& arrayPath,
155 std::uint64_t key, FieldState const* parent);
156 ~RowState();
157 RowState(RowState const&) = delete;
158 RowState& operator=(RowState const&) = delete;
159 RowState(RowState&&) = delete;
160 RowState& operator=(RowState&&) = delete;
161
162 /// @brief The row's stable key (not its position).
163 /// @return The key.
164 [[nodiscard]] std::uint64_t key() const noexcept { return _key; }
165
166 /// @brief The member fields, in `x-order`.
167 /// @return The fields.
168 [[nodiscard]] std::span<std::unique_ptr<FieldState> const> fields() const noexcept { return _fields; }
169
170 /// @brief A member by wire name.
171 /// @param name The wire name.
172 /// @return The member, or null.
173 [[nodiscard]] FieldState* field(std::string_view name) const noexcept;
174
175 /// @brief Whether every member is blank. Tracked.
176 /// @return `true` when nothing is entered in the row.
177 [[nodiscard]] bool blank() const;
178
179 /// @brief Starts every member; called once the row is built and loaded.
180 void start();
181
182private:
183 std::uint64_t _key;
184 std::vector<std::unique_ptr<FieldState>> _fields;
185};
186
187/// @brief The reactive state of one field: its draft and everything derived from it.
189public:
190 /// @param core The session core. Borrowed: it must outlive the field.
191 /// @param spec The field's spec. Borrowed from the core's model (or a parent's children).
192 /// @param path The instance path (`name`, `parent.member`, `rows#key.member`).
193 /// @param siblings The fields `x-optionsDependsOn` names are looked up among. Borrowed.
194 /// @param parent The containing Object or ObjectArray field, or null at the top level.
195 // NOLINTNEXTLINE(misc-no-recursion): an Object's or ObjectArray row's members are fields, to the model's depth.
196 FieldState(detail::engine::SessionCore& core MORPH_LIFETIMEBOUND, FieldSpec const& spec MORPH_LIFETIMEBOUND,
197 std::string path, std::vector<std::unique_ptr<FieldState>> const* siblings, FieldState const* parent)
198 : _core{&core},
199 _spec{&spec},
200 _path{std::move(path)},
201 _siblings{siblings},
202 _parent{parent},
203 _text{core.runtime(), std::string{}},
204 _unit{core.runtime(), spec.entryUnit},
205 _engaged{core.runtime(), false},
206 _rows{core.runtime(), std::vector<std::shared_ptr<RowState>>{}},
207 _encoded{core.runtime(), [this] { return computeEncoding(); }},
208 _error{core.runtime(),
209 [this] -> std::optional<FieldError> {
210 Encoding const& encoding = _encoded.get();
211 if (encoding.has_value() && !encoding->has_value()) {
212 return encoding->error();
213 }
214 return std::nullopt;
215 }},
216 _visible{core.runtime(),
217 [this] {
218 if (_spec->hidden) {
219 return false;
220 }
221 return _parent != nullptr ||
222 visibleVerdict(_core->model().rules(), _spec->name, _core->lookup()) != Tri::False;
223 }},
224 _readonly{core.runtime(),
225 [this] {
226 if (_spec->readOnly || (_parent != nullptr && _parent->readonly().get())) {
227 return true;
228 }
229 return _parent == nullptr &&
230 readonlyVerdict(_core->model().rules(), _spec->name, _core->lookup()) == Tri::True;
231 }},
232 _required{core.runtime(), [this] {
233 return _spec->required ||
234 (_parent == nullptr &&
235 requiredVerdict(_core->model().rules(), _spec->name, _core->lookup()) == Tri::True);
236 }} {
237 if (_spec->kind == FieldKind::Object) {
238 for (FieldSpec const& child : _spec->children) {
239 _members.push_back(
240 std::make_unique<FieldState>(core, child, _path + "." + child.name, &_members, this));
241 }
242 }
243 _label = resolveSlot(i18n::FieldSlot::Label, _spec->title);
244 _help = resolveSlot(i18n::FieldSlot::Help, _spec->help);
245 _placeholder = _spec->placeholder.empty() ? defaultPlaceholder(*_spec)
246 : resolveSlot(i18n::FieldSlot::Placeholder, _spec->placeholder);
247 }
248
249 ~FieldState() = default;
250 FieldState(FieldState const&) = delete;
251 FieldState& operator=(FieldState const&) = delete;
252 FieldState(FieldState&&) = delete;
253 FieldState& operator=(FieldState&&) = delete;
254
255 /// @brief The field's spec.
256 /// @return The spec.
257 [[nodiscard]] FieldSpec const& spec() const noexcept { return *_spec; }
258
259 /// @brief The instance path.
260 /// @return `name`, `parent.member`, or `rows#key.member` inside a collection row.
261 [[nodiscard]] std::string const& path() const noexcept { return _path; }
262
263 /// @brief The label: the explicit key's translation, else the derived key's (top level
264 /// only), else the schema's title.
265 /// @return The label.
266 [[nodiscard]] std::string const& label() const noexcept { return _label; }
267
268 /// @brief The help text, resolved like the label.
269 /// @return The help text; empty when the schema states none.
270 [[nodiscard]] std::string const& help() const noexcept { return _help; }
271
272 /// @brief The placeholder: `x-placeholder` resolved like the label, else `defaultPlaceholder`.
273 /// @return The placeholder.
274 [[nodiscard]] std::string const& placeholder() const noexcept { return _placeholder; }
275
276 /// @brief The draft: what the control holds. Setting it is a user edit.
277 /// @return The draft signal.
278 [[nodiscard]] reactive::Signal<std::string>& text() noexcept { return _text; }
279
280 /// @brief The draft, read-only. Tracked.
281 /// @return What the control holds.
282 [[nodiscard]] std::string const& draft() const { return _text.get(); }
283
284 /// @brief A Decimal's or Quantity's entry unit, an index into `spec().units`.
285 /// @return The unit signal.
286 [[nodiscard]] reactive::Signal<std::size_t>& unit() noexcept { return _unit; }
287
288 /// @brief Whether a blank-capable field was filled once (an `x-blankAs: empty` text, or a
289 /// collection that had rows).
290 /// @return The engagement signal.
291 [[nodiscard]] reactive::Signal<bool>& engaged() noexcept { return _engaged; }
292
293 /// @brief The draft's encoding. Tracked.
294 /// @return The encoding computed.
295 [[nodiscard]] reactive::Computed<Encoding> const& encoded() const noexcept { return _encoded; }
296
297 /// @brief Why the draft does not encode, if it does not. Tracked.
298 /// @return The error computed.
299 [[nodiscard]] reactive::Computed<std::optional<FieldError>> const& error() const noexcept { return _error; }
300
301 /// @brief Whether the field is shown: not `x-hidden`, and no `visibleWhen` naming it is False.
302 /// @return The visibility computed.
303 [[nodiscard]] reactive::Computed<bool> const& visible() const noexcept { return _visible; }
304
305 /// @brief Whether the field is frozen: `x-readonly`, a frozen parent, or a `readonlyWhen` naming it that is
306 /// True.
307 /// @return The read-only computed.
308 [[nodiscard]] reactive::Computed<bool> const& readonly() const noexcept { return _readonly; }
309
310 /// @brief Whether the field must be filled: `required`, or a `requiredWhen` naming it that is True.
311 /// @return The required computed.
312 [[nodiscard]] reactive::Computed<bool> const& required() const noexcept { return _required; }
313
314 /// @brief Whether nothing is entered: a blank draft, an Object whose members are all blank, a
315 /// collection with no rows that was never engaged. Tracked.
316 /// @return `true` when blank.
317 // NOLINTNEXTLINE(misc-no-recursion): an Object asks its members.
318 [[nodiscard]] bool blank() const {
319 if (_spec->kind == FieldKind::Object) {
320 if (_spec->truncated) {
321 return detail::engine::trim(_text.get()).empty();
322 }
323 // NOLINTNEXTLINE(misc-no-recursion): each member answers for itself.
324 return std::ranges::all_of(_members, [](auto const& entry) { return entry->blank(); });
325 }
326 if (_spec->kind == FieldKind::ObjectArray) {
327 if (_spec->truncated) {
328 return detail::engine::trim(_text.get()).empty() && !_engaged.get();
329 }
330 return _rows.get().empty() && !_engaged.get();
331 }
332 return detail::engine::trim(_text.get()).empty();
333 }
334
335 /// @brief What a rule sees of this field. Tracked.
336 /// @return Blank, Invalid, or Valid with the exact value.
337 [[nodiscard]] RuleOperand ruleOperand() const {
338 Encoding const& encoding = _encoded.get();
339 if (!encoding.has_value()) {
340 return RuleOperand{};
341 }
342 if (!encoding->has_value()) {
343 return RuleOperand{.state = RuleOperand::State::Invalid, .value = {}};
344 }
345 return RuleOperand{.state = RuleOperand::State::Valid, .value = (*encoding)->value};
346 }
347
348 /// @brief The selectable options: an Enum's from the schema, a Choice's as last fetched for
349 /// its current parents. Tracked.
350 /// @return The options; empty for every other kind, and for a Choice with nothing fetched.
351 [[nodiscard]] std::vector<ChoiceOption> const& options() const {
352 if (_fetched) {
353 return _fetched->get();
354 }
355 return _spec->enumOptions;
356 }
357
358 /// @brief Fetches a Choice's options again when the last fetch failed; does nothing otherwise. What opening its
359 /// select runs, so options that could not be fetched when the form was built are fetched once the user
360 /// asks for them.
362 if (_optionsQuery != nullptr && !_optionsQuery->pending() && _optionsQuery->error() != nullptr) {
363 _optionsQuery->refetch();
364 }
365 }
366
367 /// @brief Whether a Choice's options request is in flight. Tracked.
368 /// @return `true` while fetching.
369 [[nodiscard]] bool optionsPending() const { return _optionsQuery != nullptr && _optionsQuery->pending(); }
370
371 /// @brief Switches a Quantity's entry unit, converting the draft exactly (half away from
372 /// zero to the new unit's decimals). A user action.
373 /// @param index The new unit, an index into `spec().units`.
374 void switchUnit(std::size_t index) {
375 std::size_t const current = _unit.peek();
376 if (index >= _spec->units.size() || index == current || current >= _spec->units.size()) {
377 return;
378 }
379 auto converted =
380 convertDraft(_text.peek(), _spec->units.at(current), _spec->units.at(index), _core->values().locale);
381 _core->runtime().batch([&] {
382 _unit.set(index);
383 if (!detail::engine::trim(_text.peek()).empty()) {
384 _text.set(converted.value_or(std::string{}));
385 }
386 });
387 }
388
389 /// @brief An Object's members, in `x-order`.
390 /// @return The members; empty for every other kind.
391 [[nodiscard]] std::span<std::unique_ptr<FieldState> const> members() const noexcept { return _members; }
392
393 /// @brief An Object's member by wire name.
394 /// @param name The wire name.
395 /// @return The member, or null.
396 [[nodiscard]] FieldState* member(std::string_view name) const noexcept {
397 auto const found =
398 std::ranges::find_if(_members, [&](auto const& entry) { return entry->spec().name == name; });
399 return found == _members.end() ? nullptr : found->get();
400 }
401
402 /// @brief A collection's rows, in order. Tracked.
403 /// @return The rows; empty for every other kind.
404 [[nodiscard]] std::vector<std::shared_ptr<RowState>> const& rows() const { return _rows.get(); }
405
406 /// @brief Appends a blank row to a collection and engages it. A user action.
407 /// @return The new row's key; 0 when the field is not a collection, or a truncated one.
408 std::uint64_t addRow() {
409 if (_spec->kind != FieldKind::ObjectArray || _spec->truncated) {
410 return 0;
411 }
412 auto row = std::make_shared<RowState>(*_core, *_spec, _path, _nextRowKey++, this);
413 row->start();
414 std::uint64_t const key = row->key();
415 _core->runtime().batch([&] {
416 _rows.mutate([&](auto& rows) { rows.push_back(std::move(row)); });
417 _engaged.set(true);
418 });
419 return key;
420 }
421
422 /// @brief Removes a collection row by key. A user action. A holder of the row's `shared_ptr` (a mounted view)
423 /// keeps its state alive until it lets go.
424 /// @param key The row's key.
425 void removeRow(std::uint64_t key) {
426 _rows.mutate([&](auto& rows) { std::erase_if(rows, [&](auto const& row) { return row->key() == key; }); });
427 }
428
429 /// @brief What the field holds, as one string: two snapshots are equal exactly when the field holds the same
430 /// entry (draft, unit and engagement, and every member's and row's). Untracked.
431 /// @return The snapshot.
432 [[nodiscard]] std::string snapshot() const {
433 std::string out;
434 appendSnapshot(out);
435 return out;
436 }
437
438 /// @brief Replaces the draft with a wire value as the user's own edit, in one batch: a `load` the session does
439 /// not mark programmatic, so the field counts as changed and is validated as an edit is.
440 /// @param value The wire value; null is blank.
441 /// @return Nothing, or why the value does not fit the field: an Object takes a JSON object, a collection a JSON
442 /// array. Then nothing changes.
443 std::expected<void, std::string> edit(detail::engine::Json const& value) {
444 if (!value.is_null() && !_spec->truncated) {
445 if (_spec->kind == FieldKind::Object && !value.is_object()) {
446 return std::unexpected(std::string{"an object field takes a JSON object"});
447 }
448 if (_spec->kind == FieldKind::ObjectArray && !value.is_array()) {
449 return std::unexpected(std::string{"a list field takes a JSON array"});
450 }
451 }
452 _core->runtime().batch([&] { load(&value); });
453 return {};
454 }
455
456 /// @brief Replaces the draft with the decoded wire value; null or absent is blank. Does not
457 /// mark the change programmatic — the caller does, in the same batch.
458 /// @param value The wire value, or null.
459 // An Object loads its members, a collection its rows; one case per kind.
460 // NOLINTNEXTLINE(misc-no-recursion,readability-function-cognitive-complexity)
461 void load(detail::engine::Json const* value) {
462 using detail::engine::Json;
463 bool const present = value != nullptr && !value->is_null();
464 if (_spec->truncated) {
465 _text.set(present ? detail::engine::toJson(*value) : std::string{});
466 _engaged.set(present && value->is_array());
467 return;
468 }
469 if (_spec->kind == FieldKind::Object) {
470 for (auto const& entry : _members) {
471 entry->load(present ? detail::engine::member(*value, entry->spec().name) : nullptr);
472 }
473 return;
474 }
475 if (_spec->kind == FieldKind::ObjectArray) {
476 std::vector<std::shared_ptr<RowState>> loaded;
477 if (present && value->is_array()) {
478 for (Json const& element : value->get_array()) {
479 auto row = std::make_shared<RowState>(*_core, *_spec, _path, _nextRowKey++, this);
480 for (auto const& cell : row->fields()) {
481 cell->load(detail::engine::member(element, cell->spec().name));
482 }
483 row->start();
484 loaded.push_back(std::move(row));
485 }
486 }
487 _rows.set(std::move(loaded));
488 _engaged.set(present && value->is_array());
489 return;
490 }
491 std::string draft = present ? decodeScalar(*_spec, *value, _core->values()) : std::string{};
492 std::size_t const unit = _spec->entryUnit;
493 if (unit != 0 && unit < _spec->units.size() && !draft.empty()) {
494 // The wire value is in the canonical unit; the draft is shown in the entry unit.
495 draft = convertDraft(draft, _spec->units.front(), _spec->units.at(unit), _core->values().locale)
496 .value_or(std::string{});
497 }
498 _text.set(std::move(draft));
499 _unit.set(unit);
500 _engaged.set(_spec->blankAs == BlankAs::Empty && present && value->is_string());
501 }
502
503 /// @brief Blanks the draft, the unit, the engagement, the members and the rows.
504 // NOLINTNEXTLINE(misc-no-recursion): an Object clears its members.
505 void clear() {
506 _text.set(std::string{});
507 _unit.set(_spec->entryUnit);
508 _engaged.set(false);
509 for (auto const& entry : _members) {
510 entry->clear();
511 }
512 _rows.set({});
513 }
514
515 /// @brief Starts what needs every sibling to exist: an `x-blankAs` field's engagement, a
516 /// Choice's options query, and every member's own. Called once, after the owner built
517 /// all its fields.
518 // NOLINTNEXTLINE(misc-no-recursion): an Object starts its members.
519 void start() {
520 for (auto const& entry : _members) {
521 entry->start();
522 }
523 if (_spec->blankAs == BlankAs::Empty) {
524 _engage = std::make_unique<reactive::Effect>(_core->runtime(), [this] {
525 if (!detail::engine::trim(_text.get()).empty() && !_engaged.peek()) {
526 _engaged.set(true);
527 }
528 });
529 }
530 if (_spec->kind == FieldKind::Choice && _spec->choice.has_value() && _core->choices()) {
531 reactive::Runtime& runtime = _core->runtime();
532 _optionsQuery = std::make_unique<reactive::Query<std::string, std::string>>(
533 runtime,
534 [this](std::string const& request) { return _core->choices()(_spec->choice->optionsAction, request); },
535 [this] { return optionsRequest(); });
536 _fetched = std::make_unique<reactive::Computed<std::vector<ChoiceOption>>>(runtime, [this] {
537 std::optional<std::string> const& reply = _optionsQuery->value();
538 return reply.has_value() ? parseOptions(*reply, *_spec->choice) : std::vector<ChoiceOption>{};
539 });
540 _clearStale = std::make_unique<reactive::Effect>(runtime, [this] { clearStaleSelection(); });
541 }
542 }
543
544private:
545 // Each draft is written with its length first, so no draft can be read as the end of another.
546 // NOLINTNEXTLINE(misc-no-recursion): an Object's members and a row's cells are fields.
547 void appendSnapshot(std::string& out) const {
548 std::string const& text = _text.peek();
549 out += std::to_string(text.size());
550 out += ':';
551 out += text;
552 out += std::to_string(_unit.peek());
553 out += _engaged.peek() ? '+' : '-';
554 for (auto const& entry : _members) {
555 entry->appendSnapshot(out);
556 }
557 for (auto const& row : _rows.peek()) {
558 out += '#';
559 for (auto const& cell : row->fields()) {
560 cell->appendSnapshot(out);
561 }
562 }
563 out += ';';
564 }
565
566 [[nodiscard]] std::string resolveSlot(i18n::FieldSlot slot, std::string const& literal) const {
567 auto const& translations = _core->options().translations;
568 std::string_view const locale = _core->options().bcp47;
569 auto const explicitKey = i18n::explicitFieldKey(_spec->i18nKey, slot);
570 if (_parent == nullptr) {
571 return render::resolveText(translations, locale, explicitKey,
572 i18n::fieldKey(_core->model().actionType(), _spec->name, slot), literal);
573 }
574 if (translations && explicitKey) {
575 if (auto hit = translations(*explicitKey, locale)) {
576 return *std::move(hit);
577 }
578 }
579 return literal;
580 }
581
582 [[nodiscard]] FieldState* sibling(std::string_view name) const {
583 if (_siblings == nullptr) {
584 return nullptr;
585 }
586 auto const found =
587 std::ranges::find_if(*_siblings, [&](auto const& entry) { return entry->spec().name == name; });
588 return found == _siblings->end() ? nullptr : found->get();
589 }
590
591 /// The options request for the current parents, or nullopt while one does not encode.
592 [[nodiscard]] std::optional<std::string> optionsRequest() const {
593 std::string body = "{";
594 bool first = true;
595 // NOLINTNEXTLINE(bugprone-unchecked-optional-access): only a Choice asks, and a Choice has its spec.
596 for (std::string const& parent : _spec->choice.value().dependsOn) {
597 FieldState const* const source = sibling(parent);
598 if (source == nullptr) {
599 return std::nullopt;
600 }
601 Encoding const& encoding = source->encoded().get();
602 if (!encoding.has_value() || !encoding->has_value()) {
603 return std::nullopt;
604 }
605 body += (first ? "" : ",") + detail::engine::quote(parent) + ":" + (*encoding)->json;
606 first = false;
607 }
608 return body + "}";
609 }
610
611 /// Clears a selection the options no longer back — once the query has answered for the
612 /// current parents, so a value set before its options arrive survives the fetch. A failed
613 /// fetch judges nothing, so it keeps the selection.
614 void clearStaleSelection() {
615 if (_optionsQuery->pending() || _optionsQuery->error() != nullptr) {
616 return;
617 }
618 // An idle query (a parent is blank or does not encode) fetched nothing to judge the selection against.
619 std::optional<std::string> const request = optionsRequest();
620 if (!request.has_value()) {
621 return;
622 }
623 std::vector<ChoiceOption> const& current = _fetched->get();
624 std::string_view const selected = detail::engine::trim(_text.get());
625 if (selected.empty() ||
626 std::ranges::any_of(current, [&](ChoiceOption const& option) { return option.valueJson == selected; })) {
627 return;
628 }
629 _core->runtime().batch([&] {
630 _text.set(std::string{});
631 _core->markProgrammatic();
632 });
633 }
634
635 // NOLINTNEXTLINE(misc-no-recursion): an Object or a row encodes its members.
636 [[nodiscard]] std::expected<std::string, FieldError> encodeMembers(
637 std::span<std::unique_ptr<FieldState> const> members) const {
638 std::string json = "{";
639 bool first = true;
640 for (auto const& entry : members) {
641 Encoding const& encoding = entry->encoded().get();
642 if (!encoding.has_value()) {
643 if (entry->spec().required) {
644 return std::unexpected(fieldError("incomplete", _core->values()));
645 }
646 continue;
647 }
648 if (!encoding->has_value()) {
649 return std::unexpected(encoding->error());
650 }
651 json += (first ? "" : ",") + detail::engine::quote(entry->spec().name) + ":" + (*encoding)->json;
652 first = false;
653 }
654 return json + "}";
655 }
656
657 // An Object or a row encodes its members; one case per kind.
658 // NOLINTNEXTLINE(misc-no-recursion,readability-function-cognitive-complexity)
659 [[nodiscard]] Encoding computeEncoding() const {
660 if (_spec->truncated) {
661 std::string_view const draft = detail::engine::trim(_text.get());
662 bool const array = _spec->kind == FieldKind::ObjectArray;
663 if (draft.empty() && !_engaged.get()) {
664 return (array && _spec->required)
665 ? Encoding{WireValue{.json = "[]", .value = ui::Value{ui::ValueList{}}}}
666 : Encoding{};
667 }
668 if (array && (draft == "[]" || draft.empty())) {
669 return WireValue{.json = "[]", .value = ui::Value{ui::ValueList{}}};
670 }
671 return std::unexpected(fieldError("unrepresentable", _core->values()));
672 }
673 if (_spec->kind == FieldKind::Object) {
674 if (blank()) {
675 return std::nullopt;
676 }
677 auto json = encodeMembers(_members);
678 if (!json) {
679 return std::unexpected(json.error());
680 }
681 return WireValue{.json = *std::move(json), .value = ui::Value{}};
682 }
683 if (_spec->kind == FieldKind::ObjectArray) {
684 auto const& rows = _rows.get();
685 if (rows.empty() && !_engaged.get() && !_spec->required) {
686 return std::nullopt;
687 }
688 std::string json = "[";
689 for (std::size_t i = 0; i < rows.size(); ++i) {
690 auto row = encodeMembers(rows.at(i)->fields());
691 if (!row) {
692 return std::unexpected(row.error());
693 }
694 json += (i == 0 ? "" : ",") + *row;
695 }
696 return WireValue{.json = json + "]", .value = ui::Value{}};
697 }
698 return encodeScalar(*_spec, _text.get(), _unit.get(), _core->values());
699 }
700
701 // Declaration order is destruction order reversed: the signals precede the computeds that
702 // read them, and the effects are last, so nothing is torn down while something reads it.
703 detail::engine::SessionCore* _core;
704 FieldSpec const* _spec;
705 std::string _path;
706 std::vector<std::unique_ptr<FieldState>> const* _siblings;
707 FieldState const* _parent;
708 std::string _label;
709 std::string _help;
710 std::string _placeholder;
711 std::uint64_t _nextRowKey = 1;
712 reactive::Signal<std::string> _text;
713 reactive::Signal<std::size_t> _unit;
714 reactive::Signal<bool> _engaged;
715 reactive::Signal<std::vector<std::shared_ptr<RowState>>> _rows;
716 std::vector<std::unique_ptr<FieldState>> _members;
717 reactive::Computed<Encoding> _encoded;
718 reactive::Computed<std::optional<FieldError>> _error;
719 reactive::Computed<bool> _visible;
720 reactive::Computed<bool> _readonly;
721 reactive::Computed<bool> _required;
722 std::unique_ptr<reactive::Effect> _engage;
723 std::unique_ptr<reactive::Query<std::string, std::string>> _optionsQuery;
724 std::unique_ptr<reactive::Computed<std::vector<ChoiceOption>>> _fetched;
725 std::unique_ptr<reactive::Effect> _clearStale;
726};
727
728inline RowState::RowState(detail::engine::SessionCore& core, FieldSpec const& arraySpec, std::string const& arrayPath,
729 std::uint64_t key, FieldState const* parent)
730 : _key{key} {
731 for (FieldSpec const& member : arraySpec.children) {
732 _fields.push_back(std::make_unique<FieldState>(
733 core, member, arrayPath + "#" + std::to_string(key) + "." + member.name, &_fields, parent));
734 }
735}
736
737inline RowState::~RowState() = default;
738
739inline FieldState* RowState::field(std::string_view name) const noexcept {
740 auto const found = std::ranges::find_if(_fields, [&](auto const& entry) { return entry->spec().name == name; });
741 return found == _fields.end() ? nullptr : found->get();
742}
743
744inline bool RowState::blank() const {
745 return std::ranges::all_of(_fields, [](auto const& entry) { return entry->blank(); });
746}
747
748inline void RowState::start() {
749 for (auto const& entry : _fields) {
750 entry->start();
751 }
752}
753
754namespace detail::engine {
755
756inline FieldState* SessionCore::topLevel(std::string_view name) const {
757 if (_fields == nullptr) {
758 return nullptr;
759 }
760 auto const found = std::ranges::find_if(*_fields, [&](auto const& entry) { return entry->spec().name == name; });
761 return found == _fields->end() ? nullptr : found->get();
762}
763
764inline RuleLookup SessionCore::lookup() const {
765 return [this](std::string_view name) {
766 FieldState const* const field = topLevel(name);
767 return field == nullptr ? RuleOperand{} : field->ruleOperand();
768 };
769}
770
771} // namespace detail::engine
772
773/// @brief The reactive state of one form: a controller over a `FormModel`.
774///
775/// Non-copyable and non-movable: fields, bindings and in-flight replies point into it. Destroy a
776/// mounted view of it before the session.
778public:
779 /// @param runtime The runtime. Borrowed: it must outlive the session.
780 /// @param model The form model.
781 /// @param submit Executes the form's action. Required.
782 /// @param choices Fetches Choice options; may be empty when no field is a Choice.
783 /// @param options Locale, translations, display zone, extra readiness gate.
784 /// @throws std::invalid_argument when @p submit is empty.
787 : _core{std::make_unique<detail::engine::SessionCore>(runtime, std::move(model), std::move(choices),
788 std::move(options))},
789 _submit{std::move(submit)},
790 _assembled{runtime, [this] { return assemble(); }},
791 _ready{runtime,
792 [this] {
793 std::optional<std::string> const& assembled = _assembled.get();
794 if (!assembled.has_value()) {
795 return false;
796 }
797 auto const& accepts = _core->options().accepts;
798 return !accepts || accepts(*assembled);
799 }},
800 _body{runtime, [this] { return _ready.get() ? _assembled.get() : std::optional<std::string>{}; }},
801 _mutation{runtime,
802 [this](std::string body) { return _submit(_core->model().actionType(), std::move(body)); }},
803 _attempts{runtime, std::uint64_t{0}} {
804 if (!_submit) {
805 throw std::invalid_argument{"FormSession: a Submitter is required"};
806 }
807 for (FieldSpec const& spec : _core->model().fields()) {
808 _fields.push_back(std::make_unique<FieldState>(*_core, spec, spec.name, &_fields, nullptr));
809 }
810 _core->adopt(&_fields);
811 _core->onProgrammatic([this] { _lastSubmitted = _body.peek(); });
812 for (std::size_t i = 0; i < _core->model().groups().size(); ++i) {
813 _collapsed.push_back(std::make_unique<reactive::Signal<bool>>(runtime, false));
814 _tabs.push_back(std::make_unique<reactive::Signal<std::size_t>>(runtime, std::size_t{0}));
815 }
816 for (auto const& field : _fields) {
817 field->start();
818 }
819 markLoaded();
820 _autoSubmit = std::make_unique<reactive::Effect>(runtime, [this] { autoSubmit(); });
821 }
822
823 ~FormSession() = default;
824 FormSession(FormSession const&) = delete;
825 FormSession& operator=(FormSession const&) = delete;
826 FormSession(FormSession&&) = delete;
827 FormSession& operator=(FormSession&&) = delete;
828
829 /// @brief The form model.
830 /// @return The model.
831 [[nodiscard]] FormModel const& model() const noexcept { return _core->model(); }
832
833 /// @brief The runtime.
834 /// @return The runtime passed at construction.
835 [[nodiscard]] reactive::Runtime& runtime() const noexcept { return _core->runtime(); }
836
837 /// @brief When the form submits.
838 /// @return The model's submit mode.
839 [[nodiscard]] SubmitMode submitMode() const noexcept { return _core->model().submitMode(); }
840
841 /// @brief The session options.
842 /// @return The options.
843 [[nodiscard]] FormSessionOptions const& options() const noexcept { return _core->options(); }
844
845 /// @brief The top-level fields, in `x-order`.
846 /// @return The fields.
847 [[nodiscard]] std::span<std::unique_ptr<FieldState> const> fields() const noexcept { return _fields; }
848
849 /// @brief A row position written in a path's brackets.
850 /// @param digits The text between the brackets.
851 /// @return The position, or `std::nullopt` unless the text is all decimal digits.
852 [[nodiscard]] static std::optional<std::size_t> rowIndexOf(std::string_view digits) {
853 std::size_t parsed = 0;
854 char const* const end = std::to_address(digits.end());
855 auto const result = std::from_chars(std::to_address(digits.begin()), end, parsed);
856 if (digits.empty() || result.ec != std::errc{} || result.ptr != end) {
857 return std::nullopt;
858 }
859 return parsed;
860 }
861
862 /// @brief A field by path: `name`, `parent.member`, or `rows[i].member` (a row by position).
863 /// @param path The path.
864 /// @return The field.
865 /// @throws std::out_of_range when no field has that path.
866 [[nodiscard]] FieldState& field(std::string_view path) const {
867 FieldState* current = nullptr;
868 std::span<std::unique_ptr<FieldState> const> scope = _fields;
869 std::string_view rest = path;
870 auto const missing = [&] {
871 return std::out_of_range{"FormSession::field: no field '" + std::string{path} + "'"};
872 };
873 while (!rest.empty()) {
874 auto const dot = rest.find('.');
875 std::string_view segment = rest.substr(0, dot);
876 rest = dot == std::string_view::npos ? std::string_view{} : rest.substr(dot + 1);
877 std::optional<std::size_t> index;
878 if (auto const bracket = segment.find('['); bracket != std::string_view::npos) {
879 if (!segment.ends_with(']')) {
880 throw missing();
881 }
882 index = rowIndexOf(segment.substr(bracket + 1, segment.size() - bracket - 2));
883 if (!index.has_value()) {
884 throw missing();
885 }
886 segment = segment.substr(0, bracket);
887 }
888 auto const found =
889 std::ranges::find_if(scope, [&](auto const& entry) { return entry->spec().name == segment; });
890 if (found == scope.end()) {
891 throw missing();
892 }
893 current = found->get();
894 if (index.has_value()) {
895 auto const& rows = current->rows();
896 if (*index >= rows.size() || rest.empty()) {
897 throw missing();
898 }
899 scope = rows.at(*index)->fields();
900 } else {
901 scope = current->members();
902 if (!rest.empty() && scope.empty()) {
903 throw missing();
904 }
905 }
906 }
907 if (current == nullptr) {
908 throw missing();
909 }
910 return *current;
911 }
912
913 /// @brief Whether the form can submit. Tracked.
914 /// @return `true` when every required field encodes, no field has an error, no gating rule
915 /// is False, and `options().accepts` (when set) accepts the body.
916 [[nodiscard]] bool ready() const { return _ready.get(); }
917
918 /// @brief The body the form would submit. Tracked.
919 /// @return The JSON body, fields in `x-order`, while ready; `std::nullopt` otherwise.
920 [[nodiscard]] std::optional<std::string> body() const { return _body.get(); }
921
922 /// @brief Submits the current body; does nothing while not ready, or while a submission is in
923 /// flight (the submission is `Exclusive`). The explicit-mode trigger; in automatic mode
924 /// it forces a resubmission of an unchanged body.
925 void submit() {
926 std::optional<std::string> const body = _body.peek();
927 if (!body.has_value()) {
928 return;
929 }
930 _lastSubmitted = body;
931 if (_mutation.run(*body)) {
932 _attempts.set(_attempts.peek() + 1);
933 }
934 }
935
936 /// @brief Loads a stored payload for editing. Every field is replaced (an absent member starts
937 /// blank), every unit returns to the canonical one, and nothing is submitted. Text that
938 /// is not a JSON object changes nothing.
939 /// @param bodyJson The payload.
940 void prefill(std::string_view bodyJson) {
941 auto const dom = detail::engine::parseJson(bodyJson);
942 if (!dom || !dom->is_object()) {
943 return;
944 }
945 runtime().batch([&] {
946 for (auto const& field : _fields) {
947 field->load(detail::engine::member(*dom, field->spec().name));
948 }
949 _core->markProgrammatic();
950 });
951 markLoaded();
952 }
953
954 /// @brief Loads a stored payload like `prefill`, except that a top-level field the user changed since the form
955 /// was last loaded keeps what they entered: a later value of the record being edited, such as its first
956 /// value arriving after the user started typing, or a refetch, does not discard the edit. Nothing is
957 /// submitted. Text that is not a JSON object changes nothing.
958 /// @param bodyJson The payload.
959 void refill(std::string_view bodyJson) {
960 auto const dom = detail::engine::parseJson(bodyJson);
961 if (!dom || !dom->is_object()) {
962 return;
963 }
964 std::vector<std::size_t> loaded;
965 runtime().batch([&] {
966 for (std::size_t index = 0; index < _fields.size(); ++index) {
967 FieldState& field = *_fields.at(index);
968 if (field.snapshot() == _loaded.at(index)) {
969 field.load(detail::engine::member(*dom, field.spec().name));
970 loaded.push_back(index);
971 }
972 }
973 _core->markProgrammatic();
974 });
975 for (std::size_t const index : loaded) {
976 _loaded.at(index) = _fields.at(index)->snapshot();
977 }
978 }
979
980 /// @brief Sets one field from a JSON value, programmatically (nothing is submitted).
981 /// @param path The field's path (see `field`).
982 /// @param jsonValue The value as JSON; text that is not JSON changes nothing.
983 /// @throws std::out_of_range when no field has that path.
984 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): a path and a JSON value; swapped, the path is not found.
985 void assign(std::string_view path, std::string_view jsonValue) {
986 FieldState& target = field(path);
987 auto const dom = detail::engine::parseJson(jsonValue);
988 if (!dom) {
989 return;
990 }
991 runtime().batch([&] {
992 target.load(&*dom);
993 _core->markProgrammatic();
994 });
995 // A programmatic value is not an edit: the top-level field holding the target counts as loaded.
996 for (std::size_t index = 0; index < _fields.size(); ++index) {
997 std::string const& name = _fields.at(index)->spec().name;
998 if (path.starts_with(name) &&
999 (path.size() == name.size() || path.at(name.size()) == '.' || path.at(name.size()) == '[')) {
1000 _loaded.at(index) = _fields.at(index)->snapshot();
1001 }
1002 }
1003 }
1004
1005 /// @brief Blanks every field, programmatically (nothing is submitted).
1006 void reset() {
1007 runtime().batch([&] {
1008 for (auto const& field : _fields) {
1009 field->clear();
1010 }
1011 _core->markProgrammatic();
1012 });
1013 markLoaded();
1014 }
1015
1016 /// @brief Whether a submission is in flight. Tracked.
1017 /// @return `true` while one is.
1018 [[nodiscard]] bool pending() const { return _mutation.pending(); }
1019
1020 /// @brief The last successful reply. Tracked.
1021 /// @return The reply JSON, or `std::nullopt` before the first success.
1022 [[nodiscard]] std::optional<std::string> const& lastReply() const { return _mutation.lastResult(); }
1023
1024 /// @brief The last failure. Tracked.
1025 /// @return The exception, or null; cleared by the next success.
1026 [[nodiscard]] std::exception_ptr lastError() const { return _mutation.error(); }
1027
1028 /// @brief How many submissions have succeeded. Tracked; each success ticks it, so an observer sees every
1029 /// submission even when two replies are equal.
1030 /// @return The count.
1031 [[nodiscard]] std::uint64_t successCount() const { return _mutation.successCount(); }
1032
1033 /// @brief How many submissions have been sent, automatic and explicit. Tracked; a submission the `Exclusive`
1034 /// mutation refused while another was in flight does not count.
1035 /// @return The count.
1036 [[nodiscard]] std::uint64_t submitAttempts() const { return _attempts.get(); }
1037
1038 /// @brief An accordion group's collapsed state.
1039 /// @param group Index into `model().groups()`.
1040 /// @return The signal.
1041 /// @throws std::out_of_range for an index past the groups.
1042 [[nodiscard]] reactive::Signal<bool>& groupCollapsed(std::size_t group) const { return *_collapsed.at(group); }
1043
1044 /// @brief A tab run's selected tab, keyed by the run's first group index.
1045 /// @param group Index into `model().groups()` of the run's first tab group.
1046 /// @return The signal holding the selected tab's position in the run.
1047 /// @throws std::out_of_range for an index past the groups.
1048 [[nodiscard]] reactive::Signal<std::size_t>& tabSelection(std::size_t group) const { return *_tabs.at(group); }
1049
1050 /// @brief A group's title, resolved through the translations (key `<action>.group.<index>`).
1051 /// @param group Index into `model().groups()`.
1052 /// @return The title; empty for the implicit trailing group.
1053 /// @throws std::out_of_range for an index past the groups.
1054 [[nodiscard]] std::string groupTitle(std::size_t group) const {
1055 FieldGroupSpec const& spec = detail::engine::elementAt(_core->model().groups(), group);
1056 if (spec.implicit) {
1057 return {};
1058 }
1059 return render::resolveText(_core->options().translations, _core->options().bcp47, std::nullopt,
1060 i18n::groupKey(_core->model().actionType(), group), spec.title);
1061 }
1062
1063private:
1064 void markLoaded() {
1065 _loaded.clear();
1066 for (auto const& field : _fields) {
1067 _loaded.push_back(field->snapshot());
1068 }
1069 }
1070
1071 [[nodiscard]] std::optional<std::string> assemble() const {
1072 std::string json = "{";
1073 bool first = true;
1074 for (auto const& field : _fields) {
1075 Encoding const& encoding = field->encoded().get();
1076 bool const required = field->required().get();
1077 std::string literal;
1078 if (!encoding.has_value()) {
1079 if (field->spec().blankAs == BlankAs::Empty && field->engaged().get() && !required) {
1080 literal = R"("")";
1081 } else if (required) {
1082 return std::nullopt;
1083 } else {
1084 continue;
1085 }
1086 } else if (!encoding->has_value()) {
1087 return std::nullopt;
1088 } else {
1089 literal = (*encoding)->json;
1090 }
1091 json += (first ? "" : ",") + detail::engine::quote(field->spec().name) + ":" + literal;
1092 first = false;
1093 }
1094 if (gatingVerdict(_core->model().rules(), _core->lookup()) == Tri::False) {
1095 return std::nullopt;
1096 }
1097 return json + "}";
1098 }
1099
1100 void autoSubmit() {
1101 if (_core->model().submitMode() != SubmitMode::Automatic) {
1102 return;
1103 }
1104 // While a Choice's options are in flight its selection is unjudged: a parent just changed, and the selection
1105 // may be one the new options no longer back. Waiting lets the stale selection be cleared first; the settled
1106 // query wakes this Effect again.
1107 if (std::ranges::any_of(_fields, [](auto const& field) { return field->optionsPending(); })) {
1108 return;
1109 }
1110 // The submission is Exclusive: while one is in flight a new run would be refused and its body lost. Waiting
1111 // here sends the latest body once the one in flight settles, which wakes this Effect again.
1112 if (_mutation.pending()) {
1113 return;
1114 }
1115 std::optional<std::string> const& body = _body.get();
1116 if (!body.has_value() || body == _lastSubmitted) {
1117 return;
1118 }
1119 _lastSubmitted = body;
1120 std::string const toSend = *body;
1121 runtime().untracked([&] {
1122 if (_mutation.run(toSend)) {
1123 _attempts.set(_attempts.peek() + 1);
1124 }
1125 });
1126 }
1127
1128 std::unique_ptr<detail::engine::SessionCore> _core;
1129 Submitter _submit;
1130 std::vector<std::unique_ptr<FieldState>> _fields;
1131 std::vector<std::unique_ptr<reactive::Signal<bool>>> _collapsed;
1132 std::vector<std::unique_ptr<reactive::Signal<std::size_t>>> _tabs;
1133 reactive::Computed<std::optional<std::string>> _assembled;
1134 reactive::Computed<bool> _ready;
1135 reactive::Computed<std::optional<std::string>> _body;
1136 reactive::Mutation<std::string, std::string> _mutation;
1137 reactive::Signal<std::uint64_t> _attempts;
1138 std::optional<std::string> _lastSubmitted;
1139 /// @brief Each top-level field's snapshot as last loaded; a field whose snapshot differs was edited since.
1140 std::vector<std::string> _loaded;
1141 std::unique_ptr<reactive::Effect> _autoSubmit;
1142};
1143
1144} // namespace morph::forms
Node text(TextOptions const &options)
A text node.
Definition author.hpp:3126
Handle< T > row()
The innermost row, {"row": ""}.
Definition author.hpp:1145
The reactive state of one field: its draft and everything derived from it.
void switchUnit(std::size_t index)
Switches a Quantity's entry unit, converting the draft exactly (half away from zero to the new unit's...
RuleOperand ruleOperand() const
What a rule sees of this field. Tracked.
void retryOptions()
Fetches a Choice's options again when the last fetch failed; does nothing otherwise....
std::expected< void, std::string > edit(detail::engine::Json const &value)
Replaces the draft with a wire value as the user's own edit, in one batch: a load the session does no...
bool optionsPending() const
Whether a Choice's options request is in flight. Tracked.
std::vector< ChoiceOption > const & options() const
The selectable options: an Enum's from the schema, a Choice's as last fetched for its current parents...
std::string const & draft() const
The draft, read-only. Tracked.
std::vector< std::shared_ptr< RowState > > const & rows() const
A collection's rows, in order. Tracked.
void load(detail::engine::Json const *value)
Replaces the draft with the decoded wire value; null or absent is blank. Does not mark the change pro...
std::string const & placeholder() const noexcept
The placeholder: x-placeholder resolved like the label, else defaultPlaceholder.
reactive::Computed< Encoding > const & encoded() const noexcept
The draft's encoding. Tracked.
std::string snapshot() const
What the field holds, as one string: two snapshots are equal exactly when the field holds the same en...
reactive::Signal< bool > & engaged() noexcept
Whether a blank-capable field was filled once (an x-blankAs: empty text, or a collection that had row...
std::string const & path() const noexcept
The instance path.
void clear()
Blanks the draft, the unit, the engagement, the members and the rows.
reactive::Computed< bool > const & visible() const noexcept
Whether the field is shown: not x-hidden, and no visibleWhen naming it is False.
bool blank() const
Whether nothing is entered: a blank draft, an Object whose members are all blank, a collection with n...
reactive::Computed< bool > const & required() const noexcept
Whether the field must be filled: required, or a requiredWhen naming it that is True.
reactive::Computed< bool > const & readonly() const noexcept
Whether the field is frozen: x-readonly, a frozen parent, or a readonlyWhen naming it that is True.
FieldSpec const & spec() const noexcept
The field's spec.
void removeRow(std::uint64_t key)
Removes a collection row by key. A user action. A holder of the row's shared_ptr (a mounted view) kee...
reactive::Signal< std::string > & text() noexcept
The draft: what the control holds. Setting it is a user edit.
void start()
Starts what needs every sibling to exist: an x-blankAs field's engagement, a Choice's options query,...
FieldState * member(std::string_view name) const noexcept
An Object's member by wire name.
reactive::Signal< std::size_t > & unit() noexcept
A Decimal's or Quantity's entry unit, an index into spec().units.
std::string const & help() const noexcept
The help text, resolved like the label.
std::string const & label() const noexcept
The label: the explicit key's translation, else the derived key's (top level only),...
std::span< std::unique_ptr< FieldState > const > members() const noexcept
An Object's members, in x-order.
FieldState(detail::engine::SessionCore &core, FieldSpec const &spec, std::string path, std::vector< std::unique_ptr< FieldState > > const *siblings, FieldState const *parent)
std::uint64_t addRow()
Appends a blank row to a collection and engages it. A user action.
reactive::Computed< std::optional< FieldError > > const & error() const noexcept
Why the draft does not encode, if it does not. Tracked.
The immutable runtime model of one action's form.
FieldState & field(std::string_view path) const
A field by path: name, parent.member, or rows[i].member (a row by position).
reactive::Runtime & runtime() const noexcept
The runtime.
std::string groupTitle(std::size_t group) const
A group's title, resolved through the translations (key <action>.group.<index>).
static std::optional< std::size_t > rowIndexOf(std::string_view digits)
A row position written in a path's brackets.
std::span< std::unique_ptr< FieldState > const > fields() const noexcept
The top-level fields, in x-order.
FormSession(reactive::Runtime &runtime, FormModel model, Submitter submit, ChoiceFetcher choices, FormSessionOptions options={})
bool pending() const
Whether a submission is in flight. Tracked.
void assign(std::string_view path, std::string_view jsonValue)
Sets one field from a JSON value, programmatically (nothing is submitted).
std::exception_ptr lastError() const
The last failure. Tracked.
std::uint64_t successCount() const
How many submissions have succeeded. Tracked; each success ticks it, so an observer sees every submis...
void prefill(std::string_view bodyJson)
Loads a stored payload for editing. Every field is replaced (an absent member starts blank),...
std::optional< std::string > body() const
The body the form would submit. Tracked.
void reset()
Blanks every field, programmatically (nothing is submitted).
SubmitMode submitMode() const noexcept
When the form submits.
reactive::Signal< bool > & groupCollapsed(std::size_t group) const
An accordion group's collapsed state.
bool ready() const
Whether the form can submit. Tracked.
std::optional< std::string > const & lastReply() const
The last successful reply. Tracked.
void refill(std::string_view bodyJson)
Loads a stored payload like prefill, except that a top-level field the user changed since the form wa...
std::uint64_t submitAttempts() const
How many submissions have been sent, automatic and explicit. Tracked; a submission the Exclusive muta...
void submit()
Submits the current body; does nothing while not ready, or while a submission is in flight (the submi...
FormModel const & model() const noexcept
The form model.
reactive::Signal< std::size_t > & tabSelection(std::size_t group) const
A tab run's selected tab, keyed by the run's first group index.
FormSessionOptions const & options() const noexcept
The session options.
One collection row: the element's member fields.
FieldState * field(std::string_view name) const noexcept
A member by wire name.
RowState(detail::engine::SessionCore &core, FieldSpec const &arraySpec, std::string const &arrayPath, std::uint64_t key, FieldState const *parent)
std::uint64_t key() const noexcept
The row's stable key (not its position).
void start()
Starts every member; called once the row is built and loaded.
std::span< std::unique_ptr< FieldState > const > fields() const noexcept
The member fields, in x-order.
bool blank() const
Whether every member is blank. Tracked.
A derived value: computed lazily on first read, cached, and recomputed only after a source it read re...
Definition signal.hpp:168
The owner of a signal graph: every node takes one, and every operation runs on its owner.
Definition runtime.hpp:70
A source value. Reading it with get() inside a tracked run subscribes that run.
Definition signal.hpp:49
std::string const & name() const noexcept
The element's id.
Definition author.hpp:1243
Declarative control: Query, Mutation and Subscription turn server interaction into reactive state,...
FormModel: the immutable runtime model of one action's form, read from the schema JSON schemaJson<A>(...
@ Choice
A set fetched by executing an options action.
@ Object
A nested object.
@ ObjectArray
A list of nested objects.
SubmitMode
When a form submits.
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.
Stable, schema-derived message-key vocabulary for renderer i18n.
std::string groupKey(std::string_view actionTypeId, std::size_t groupIndex)
The derived message key for a layout group's title.
Definition i18n.hpp:116
FieldSlot
Which of a field's three translatable texts a message key names.
Definition i18n.hpp:35
@ Placeholder
The field's empty-state placeholder text (<stem>.placeholder).
Definition i18n.hpp:38
@ Help
The field's help/description text (<stem>.help).
Definition i18n.hpp:37
@ Label
The field's display label (<stem>.label).
Definition i18n.hpp:36
Locale numeric-entry normalisation for the control edge.
The renderer-side translation-catalog seam over morph's derived message keys (morph::forms::i18n,...
std::function< std::optional< std::string >(std::string_view key, std::string_view bcp47Locale)> TranslationProvider
A host-supplied catalog lookup: key/bcp47Locale -> translated text, or std::nullopt on a miss.
Definition i18n.hpp:27
std::string resolveText(const TranslationProvider &provider, std::string_view bcp47Locale, const std::optional< std::string > &explicitKey, std::string_view derivedKey, std::string_view schemaLiteral)
Resolves one display slot's text: explicit key, then derived key, then the schema literal.
Definition i18n.hpp:65
x-rules read into RuleExpr trees, translated into the UI document's expression language (expr/1) and ...
std::function< RuleOperand(std::string_view field)> RuleLookup
Reads one field by wire name, for a rule. The session's lookup is tracked, so a verdict computed insi...
Definition rules.hpp:84
Tri readonlyVerdict(std::span< RuleExpr const > rules, std::string_view field, RuleLookup const &lookup)
Whether field is frozen: as soon as one readonlyWhen naming it is true.
Definition rules.hpp:808
@ True
Definitely holds.
Definition rules.hpp:48
@ False
Definitely does not hold.
Definition rules.hpp:47
Tri requiredVerdict(std::span< RuleExpr const > rules, std::string_view field, RuleLookup const &lookup)
Whether field is required by a rule: as soon as one requiredWhen naming it is true.
Definition rules.hpp:818
Tri visibleVerdict(std::span< RuleExpr const > rules, std::string_view field, RuleLookup const &lookup)
Whether field is shown: hidden as soon as one visibleWhen naming it is false.
Definition rules.hpp:798
Tri gatingVerdict(std::span< RuleExpr const > rules, RuleLookup const &lookup)
Whether the gating rules let the form submit.
Definition rules.hpp:743
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.
void request(Store< ViewState, Msg > &store, bridge::BridgeHandler< Model, Sharing > &handler, async::CallbackScope const &scope, Action action, ToMsg toMsg, ToFailMsg toFailMsg)
Executes action and sends its result, or its failure, to store as one message.
Definition store.hpp:202
One x-layout group, or the implicit group of the fields no group names.
bool implicit
The trailing group of fields no declared group names (or every field, without x-layout).
std::string title
The group's title (empty for the implicit group).
Everything the engine knows about one field, from its schema property.
std::vector< FieldSpec > children
An Object's members, or an ObjectArray's element members, in x-order.
How a session reads numbers, dates and text.
render::NumericLocale locale
How numbers are typed and shown. It holds views: the strings it names must outlive the session.
std::function< bool(std::string_view bodyJson)> accepts
An extra readiness gate over the assembled body; empty means none. Form<A> puts the action's own vali...
std::string bcp47
The locale translations are resolved in.
render::TranslationProvider translations
The host's catalog; empty means every label is the schema's literal.
int displayOffsetMinutes
The display zone for date-times, in minutes east of UTC.
What a rule reads of one field.
Definition rules.hpp:68
@ Valid
Entered and encodes; value holds the exact value.
Definition rules.hpp:73
@ Invalid
Something entered that does not encode.
Definition rules.hpp:72
The locale facts both control-edge conversions need, in one designated-initialisable aggregate.
Exact text helpers the forms engine shares: JSON quoting and reading, canonical decimal comparison an...
The forms engine's encoders and decoders: a field's draft text to its exact wire literal,...
Encoding encodeScalar(FieldSpec const &spec, std::string_view draft, std::size_t unit, ValueContext const &context)
Encodes one scalar field's draft (every kind but Object and ObjectArray).
Definition values.hpp:347
FieldError fieldError(std::string_view code, ValueContext const &context)
The error for code, its message resolved through context's translations.
Definition values.hpp:83
std::optional< std::string > convertDraft(std::string_view draft, UnitOption const &from, UnitOption const &target, render::NumericLocale const &locale)
Converts a draft from one unit to another exactly, rounding half away from zero to the target unit's ...
Definition values.hpp:535
std::string decodeScalar(FieldSpec const &spec, detail::engine::Json const &value, ValueContext const &context)
The draft a control shows for the wire value value — the inverse of encodeScalar, so encoding the res...
Definition values.hpp:439
std::optional< std::expected< WireValue, FieldError > > Encoding
A draft's encoding: std::nullopt when blank, else the value or why it does not encode.
Definition values.hpp:65
std::string defaultPlaceholder(FieldSpec const &spec)
The placeholder a control shows when the schema declares none.
Definition values.hpp:594