morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
wizard.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4
5/// @file forms/engine/wizard.hpp
6/// @brief Wizards, headless: a `w-*` document as one `FormSession` per step, sequenced by successful submissions,
7/// with each step's prefill taken from what earlier steps resolved.
8///
9/// Specified in `docs/spec/forms/engine.md`, "Wizards".
10
11#include <cstddef>
12#include <cstdint>
13#include <exception>
14#include <expected>
15#include <functional>
16#include <map>
17#include <memory>
18#include <optional>
19#include <span>
20#include <stdexcept>
21#include <string>
22#include <string_view>
23#include <utility>
24#include <vector>
25
26#include "../../attributes.hpp"
27#include "../../core/callback_scope.hpp"
28#include "../../core/completion.hpp"
31#include "../../render/i18n.hpp"
32#include "../i18n.hpp"
33#include "collection.hpp"
34#include "detail/text.hpp"
35#include "field_model.hpp"
36#include "form_session.hpp"
37#include "rules.hpp"
38
39namespace morph::forms {
40
41/// @brief One `w-steps` entry.
43 /// @brief The step's action type.
44 std::string action;
45 /// @brief The step's title: `title`, else the action type.
46 std::string title;
47 /// @brief `{field: "Action.member"}`: what entering the step fills in, in document order.
48 std::vector<std::pair<std::string, std::string>> prefill;
49};
50
51/// @brief The immutable model of a `w-*` wizard document.
52class WizardModel {
53public:
54 /// @brief Reads a wizard document.
55 /// @param wizardId The wizard's registered id, the stem of its i18n keys.
56 /// @param wizardJson The document, in the vocabulary `flows::wizardSchemaJson<W>()` emits.
57 /// @return The model, or the error naming the malformed key.
58 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): an id and a JSON document; swapped, the document fails.
59 [[nodiscard]] static std::expected<WizardModel, SchemaError> fromSchema(std::string_view wizardId,
60 std::string_view wizardJson) {
61 using detail::engine::Json;
62 auto const dom = detail::engine::parseJson(wizardJson);
63 if (!dom || !dom->is_object()) {
64 return std::unexpected(SchemaError{.path = "", .message = "a wizard document must be a JSON object"});
65 }
66 WizardModel model{};
67 model._id = wizardId;
68 auto const* const title = detail::engine::stringAt(*dom, "w-title");
69 model._title = title != nullptr ? *title : std::string{wizardId};
70 Json const* const steps = detail::engine::member(*dom, "w-steps");
71 if (steps == nullptr || !steps->is_array()) {
72 return std::unexpected(
73 SchemaError{.path = "w-steps", .message = R"(a wizard document needs a "w-steps" array)"});
74 }
75 std::size_t index = 0;
76 for (Json const& entry : steps->get_array()) {
77 std::string const path = "w-steps[" + std::to_string(index++) + "]";
78 auto const* const action = detail::engine::stringAt(entry, "action");
79 if (action == nullptr) {
80 return std::unexpected(SchemaError{.path = path, .message = R"(a step needs an "action")"});
81 }
82 WizardStepSpec step{};
83 step.action = *action;
84 auto const* const stepTitle = detail::engine::stringAt(entry, "title");
85 step.title = stepTitle != nullptr ? *stepTitle : step.action;
86 if (Json const* const prefill = detail::engine::member(entry, "prefill")) {
87 if (!prefill->is_object()) {
88 return std::unexpected(
89 SchemaError{.path = path + ".prefill", .message = R"("prefill" must be an object)"});
90 }
91 for (auto const& [field, source] : prefill->get_object()) {
92 auto const* const text = source.get_if<std::string>();
93 if (text == nullptr || !text->contains('.')) {
94 std::string where = path;
95 where += ".prefill.";
96 where += field;
97 return std::unexpected(SchemaError{.path = std::move(where),
98 .message = R"(a prefill path is "<Action>.<member>")"});
99 }
100 step.prefill.emplace_back(field, *text);
101 }
102 }
103 model._steps.push_back(std::move(step));
104 }
105 if (model._steps.empty()) {
106 return std::unexpected(SchemaError{.path = "w-steps", .message = "a wizard needs at least one step"});
107 }
108 return model;
109 }
110
111 /// @brief The wizard's id.
112 /// @return The id passed to `fromSchema`.
113 [[nodiscard]] std::string_view id() const noexcept { return _id; }
114
115 /// @brief `w-title`, or the id.
116 /// @return The title.
117 [[nodiscard]] std::string_view title() const noexcept { return _title; }
118
119 /// @brief The steps, in order; never empty.
120 /// @return The steps.
121 [[nodiscard]] std::span<WizardStepSpec const> steps() const noexcept { return _steps; }
122
123private:
124 WizardModel() = default;
125
126 std::string _id;
127 std::string _title;
128 std::vector<WizardStepSpec> _steps;
129};
130
131/// @brief The reactive state of one wizard: its steps' sessions, the position, and what each step resolved.
132///
133/// A step is **done** while its current body is the one whose submission last succeeded: editing a done step
134/// makes it not done until that body succeeds. Next moves on from a done step without submitting; from a step that
135/// is ready but not done it submits and moves on once that submission succeeds. Back always returns, keeping every
136/// step's values. Entering a step applies its prefill from the resolved values as JSON values (programmatic, so it
137/// submits nothing by itself); a step left ready and not done by its prefill submits once in automatic mode.
138///
139/// Non-copyable and non-movable: the steps' submitters point into it. Destroy a view of it before the session.
141public:
142 /// @param runtime The runtime. Borrowed: it must outlive the session.
143 /// @param model The wizard model.
144 /// @param schemas Finds each step's schema.
145 /// @param submit Executes every step's action. Required.
146 /// @param choices Fetches the steps' Choice options; may be empty.
147 /// @param options Every step's session options.
148 /// @throws std::invalid_argument when @p submit is empty, or a step's schema is unknown or does not read.
149 // The schema lookup, the submitter and the fetcher are distinct roles of one shape.
150 // NOLINTBEGIN(bugprone-easily-swappable-parameters)
151 WizardSession(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, WizardModel model, SchemaLookup const& schemas,
152 Submitter submit, ChoiceFetcher const& choices, FormSessionOptions const& options = {})
153 // NOLINTEND(bugprone-easily-swappable-parameters)
154 : _rt{&runtime}, _model{std::move(model)}, _submit{std::move(submit)}, _current{runtime, std::size_t{0}} {
155 if (!_submit) {
156 throw std::invalid_argument{"WizardSession: a Submitter is required"};
157 }
158 for (std::size_t i = 0; i < _model.steps().size(); ++i) {
159 WizardStepSpec const& spec = detail::engine::elementAt(_model.steps(), i);
160 auto const schema = schemas ? schemas(spec.action) : std::nullopt;
161 if (!schema) {
162 throw std::invalid_argument{"WizardSession: no schema for step '" + spec.action + "'"};
163 }
164 auto stepModel = FormModel::fromSchema(spec.action, *schema);
165 if (!stepModel) {
166 throw std::invalid_argument{"WizardSession: the schema of step '" + spec.action + "' does not read: " +
167 stepModel.error().path + ": " + stepModel.error().message};
168 }
169 _succeeded.push_back(
170 std::make_unique<reactive::Signal<std::optional<std::string>>>(runtime, std::nullopt));
171 _steps.push_back(std::make_unique<FormSession>(
172 runtime, *std::move(stepModel),
173 [this, i](std::string_view action, std::string body) { return relay(i, action, std::move(body)); },
174 choices, options));
175 }
176 }
177
178 ~WizardSession() = default;
179 WizardSession(WizardSession const&) = delete;
180 WizardSession& operator=(WizardSession const&) = delete;
181 WizardSession(WizardSession&&) = delete;
182 WizardSession& operator=(WizardSession&&) = delete;
183
184 /// @brief The wizard model.
185 /// @return The model.
186 [[nodiscard]] WizardModel const& model() const noexcept { return _model; }
187
188 /// @brief The current step. Tracked.
189 /// @return Its index.
190 [[nodiscard]] std::size_t current() const { return _current.get(); }
191
192 /// @brief How many steps there are.
193 /// @return The count; at least one.
194 [[nodiscard]] std::size_t stepCount() const noexcept { return _steps.size(); }
195
196 /// @brief A step's form session.
197 /// @param index The step.
198 /// @return Its session, alive as long as the wizard.
199 /// @throws std::out_of_range for an index past the steps.
200 [[nodiscard]] FormSession& step(std::size_t index) const { return *_steps.at(index); }
201
202 /// @brief Whether a step is done: its current body is the one whose submission last succeeded. Tracked.
203 /// @param index The step.
204 /// @return `true` when done.
205 /// @throws std::out_of_range for an index past the steps.
206 [[nodiscard]] bool stepDone(std::size_t index) const {
207 std::optional<std::string> const& succeeded = _succeeded.at(index)->get();
208 return succeeded.has_value() && _steps.at(index)->body() == succeeded;
209 }
210
211 /// @brief Whether Back is possible. Tracked.
212 /// @return `true` past the first step.
213 [[nodiscard]] bool canBack() const { return current() > 0; }
214
215 /// @brief Whether Next is possible. Tracked.
216 /// @return `true` when the current step is not the last, and is done, or ready with no submission in flight.
217 [[nodiscard]] bool canNext() const {
218 std::size_t const here = current();
219 if (here + 1 >= _steps.size()) {
220 return false;
221 }
222 FormSession const& session = *_steps.at(here);
223 return stepDone(here) || (session.ready() && !session.pending());
224 }
225
226 /// @brief Whether the current step is the last. Tracked.
227 /// @return `true` on the last step.
228 [[nodiscard]] bool onLastStep() const { return current() + 1 >= _steps.size(); }
229
230 /// @brief Moves on: at once from a done step; from a ready step after submitting its body and that submission
231 /// succeeding. Does nothing while `canNext()` is false.
232 void next() {
233 if (!_rt->untracked([&] { return canNext(); })) {
234 return;
235 }
236 std::size_t const here = _current.peek();
237 if (_rt->untracked([&] { return stepDone(here); })) {
238 enter(here + 1);
239 return;
240 }
241 _advanceFrom = here;
242 _steps.at(here)->submit();
243 }
244
245 /// @brief Returns to the previous step, its values as they were; nothing is submitted.
246 void back() {
247 _advanceFrom.reset();
248 if (_current.peek() > 0) {
249 _current.set(_current.peek() - 1);
250 }
251 }
252
253 /// @brief A value an earlier successful submission resolved: its body's member, or its reply's, the reply
254 /// winning when both have one. Not tracked.
255 /// @param path `<ActionType>.<member>`.
256 /// @return The member's JSON, or `std::nullopt`.
257 [[nodiscard]] std::optional<std::string> resolved(std::string_view path) const {
258 auto const found = _resolved.find(path);
259 return found == _resolved.end() ? std::nullopt : std::optional<std::string>{found->second};
260 }
261
262 /// @brief The wizard's title, resolved through the translations (`<id>.title`).
263 /// @return The title.
264 [[nodiscard]] std::string title() const {
265 FormSessionOptions const& options = _steps.front()->options();
266 return render::resolveText(options.translations, options.bcp47, std::nullopt,
267 i18n::wizardTitleKey(_model.id()), _model.title());
268 }
269
270 /// @brief A step's title, resolved through the translations (`<id>.step.<index>.title`).
271 /// @param index The step.
272 /// @return The title.
273 /// @throws std::out_of_range for an index past the steps.
274 [[nodiscard]] std::string stepTitle(std::size_t index) const {
275 FormSessionOptions const& options = _steps.at(index)->options();
276 return render::resolveText(options.translations, options.bcp47, std::nullopt,
277 i18n::wizardStepTitleKey(_model.id(), index),
278 detail::engine::elementAt(_model.steps(), index).title);
279 }
280
281private:
282 void enter(std::size_t index) {
283 _advanceFrom.reset();
284 FormSession& session = *_steps.at(index);
285 _rt->batch([&] {
286 _current.set(index);
287 for (auto const& [field, path] : detail::engine::elementAt(_model.steps(), index).prefill) {
288 if (auto const value = resolved(path)) {
289 session.assign(field, *value);
290 }
291 }
292 });
293 bool const submitNow = _rt->untracked(
294 [&] { return session.submitMode() == SubmitMode::Automatic && session.ready() && !stepDone(index); });
295 if (submitNow) {
296 session.submit();
297 }
298 }
299
300 [[nodiscard]] async::Completion<std::string> relay(std::size_t index, std::string_view action, std::string body) {
301 auto [completion, promise] = async::Completion<std::string>::makeSettleable(&_rt->owner());
302 auto const settle = std::make_shared<async::Completion<std::string>::Promise>(std::move(promise));
303 std::string submitted = body;
304 std::string type{action};
305 _submit(action, std::move(body))
306 .then(_callbacks,
307 [this, index, settle, submitted = std::move(submitted),
308 type = std::move(type)](std::string const& reply) {
309 _rt->batch([&] {
310 record(type, submitted);
311 record(type, reply);
312 _succeeded.at(index)->set(submitted);
313 });
314 settle->resolve(reply);
315 if (_advanceFrom == index && _current.peek() == index && index + 1 < _steps.size()) {
316 enter(index + 1);
317 }
318 })
319 .onError(_callbacks, [this, index, settle](std::exception_ptr const& error) {
320 _succeeded.at(index)->set(std::nullopt);
321 if (_advanceFrom == index) {
322 _advanceFrom.reset();
323 }
324 settle->reject(error);
325 });
326 return std::move(completion);
327 }
328
329 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): an action type and a JSON document.
330 void record(std::string const& action, std::string const& json) {
331 auto const dom = detail::engine::parseJson(json);
332 if (!dom || !dom->is_object()) {
333 return;
334 }
335 for (auto const& [name, value] : dom->get_object()) {
336 std::string path = action;
337 path += '.';
338 path += name;
339 _resolved.insert_or_assign(std::move(path), detail::engine::toJson(value));
340 }
341 }
342
343 reactive::Runtime* _rt;
344 WizardModel _model;
345 Submitter _submit;
346 reactive::Signal<std::size_t> _current;
347 std::vector<std::unique_ptr<reactive::Signal<std::optional<std::string>>>> _succeeded;
348 std::map<std::string, std::string, std::less<>> _resolved;
349 std::optional<std::size_t> _advanceFrom;
350 // Before the steps: a step ready as constructed submits from its constructor through `relay`, whose replies
351 // this scope gates.
352 async::CallbackScope _callbacks;
353 std::vector<std::unique_ptr<FormSession>> _steps;
354};
355
356} // namespace morph::forms
AnyHandle session(std::string const &name)
A session attribute, {"session": name}.
Definition author.hpp:1214
static std::pair< Completion< T >, Promise > makeSettleable(::morph::exec::IExecutor *execPtr)
Constructs a Completion<T>/Promise<T> pair sharing one settleable state.
static std::expected< FormModel, SchemaError > fromSchema(std::string_view actionType, std::string_view schemaJson, FieldOverlays const &overlays={})
Reads a schema document, with the overlays a document or an application states for its fields.
The reactive state of one form: a controller over a FormModel.
The immutable model of a w-* wizard document.
Definition wizard.hpp:52
std::string_view title() const noexcept
w-title, or the id.
Definition wizard.hpp:117
std::span< WizardStepSpec const > steps() const noexcept
The steps, in order; never empty.
Definition wizard.hpp:121
std::string_view id() const noexcept
The wizard's id.
Definition wizard.hpp:113
static std::expected< WizardModel, SchemaError > fromSchema(std::string_view wizardId, std::string_view wizardJson)
Reads a wizard document.
Definition wizard.hpp:59
WizardSession(reactive::Runtime &runtime, WizardModel model, SchemaLookup const &schemas, Submitter submit, ChoiceFetcher const &choices, FormSessionOptions const &options={})
Definition wizard.hpp:151
WizardModel const & model() const noexcept
The wizard model.
Definition wizard.hpp:186
std::string title() const
The wizard's title, resolved through the translations (<id>.title).
Definition wizard.hpp:264
bool canBack() const
Whether Back is possible. Tracked.
Definition wizard.hpp:213
bool onLastStep() const
Whether the current step is the last. Tracked.
Definition wizard.hpp:228
FormSession & step(std::size_t index) const
A step's form session.
Definition wizard.hpp:200
std::optional< std::string > resolved(std::string_view path) const
A value an earlier successful submission resolved: its body's member, or its reply's,...
Definition wizard.hpp:257
void next()
Moves on: at once from a done step; from a ready step after submitting its body and that submission s...
Definition wizard.hpp:232
std::size_t current() const
The current step. Tracked.
Definition wizard.hpp:190
bool stepDone(std::size_t index) const
Whether a step is done: its current body is the one whose submission last succeeded....
Definition wizard.hpp:206
void back()
Returns to the previous step, its values as they were; nothing is submitted.
Definition wizard.hpp:246
bool canNext() const
Whether Next is possible. Tracked.
Definition wizard.hpp:217
std::string stepTitle(std::size_t index) const
A step's title, resolved through the translations (<id>.step.<index>.title).
Definition wizard.hpp:274
std::size_t stepCount() const noexcept
How many steps there are.
Definition wizard.hpp:194
The owner of a signal graph: every node takes one, and every operation runs on its owner.
Definition runtime.hpp:70
decltype(auto) batch(F &&fn)
Runs fn as one batch: every write inside it is one flush.
Definition runtime.hpp:104
void set(T value)
Replaces the value and notifies observers, unless it equals the current one and the policy is Equalit...
Definition signal.hpp:90
Collections, headless: a v-* view document as a reactive list of rows, the table source the table eng...
std::function< std::optional< std::string >(std::string_view actionType)> SchemaLookup
Finds an action's schema JSON by its type id (a registry, a map, a served catalog).
FormModel: the immutable runtime model of one action's form, read from the schema JSON schemaJson<A>(...
FormSession: the reactive state of one form over a FormModel — drafts, encoded values,...
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 wizardTitleKey(std::string_view wizardId)
The derived message key for a wizard's own title.
Definition i18n.hpp:137
std::string wizardStepTitleKey(std::string_view wizardId, std::size_t stepIndex)
The derived message key for one wizard step's title.
Definition i18n.hpp:147
The renderer-side translation-catalog seam over morph's derived message keys (morph::forms::i18n,...
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 ...
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.
How a session reads numbers, dates and text.
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.
Why a schema does not read as a form: where, and why.
Definition rules.hpp:34
One w-steps entry.
Definition wizard.hpp:42
std::string title
The step's title: title, else the action type.
Definition wizard.hpp:46
std::vector< std::pair< std::string, std::string > > prefill
{field: "Action.member"}: what entering the step fills in, in document order.
Definition wizard.hpp:48
std::string action
The step's action type.
Definition wizard.hpp:44
Exact text helpers the forms engine shares: JSON quoting and reading, canonical decimal comparison an...