morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
field_model.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4
5/// @file forms/engine/field_model.hpp
6/// @brief `FormModel`: the immutable runtime model of one action's form, read from the schema
7/// JSON `schemaJson<A>()` emits (or any document in that vocabulary).
8///
9/// Specified in `docs/spec/forms/engine.md`, "The field model".
10
11#include <algorithm>
12#include <cmath>
13#include <compare>
14#include <cstddef>
15#include <cstdint>
16#include <expected>
17#include <iterator>
18#include <map>
19#include <optional>
20#include <span>
21#include <stdexcept>
22#include <string>
23#include <string_view>
24#include <utility>
25#include <vector>
26
28#include "../blank_as.hpp"
29#include "../layout.hpp"
30#include "detail/text.hpp"
31#include "rules.hpp"
32
33namespace morph::forms {
34
35/// @brief The control a field gets, which the JSON type alone does not say.
36enum class FieldKind : std::uint8_t {
37 Text, ///< A string.
38 Multiline, ///< A string edited over several lines (`x-widget: textarea`).
39 Integer, ///< An integer of any width; every digit is kept.
40 Number, ///< A plain JSON number (a `double`/`float` member).
41 Decimal, ///< An exact `{num, den, dp}` with a declared precision and no unit.
42 Quantity, ///< An exact `{num, den, dp}` in a unit, with optional convertible alternatives.
43 Boolean, ///< `true` / `false`.
44 Enum, ///< A closed set the schema states.
45 Choice, ///< A set fetched by executing an options action.
46 DateTime, ///< An instant, entered in the display zone, sent as ISO-8601 UTC.
47 Date, ///< A calendar date, `YYYY-MM-DD`.
48 EpochDateTime, ///< An instant as int64 seconds since the epoch (UTC): `x-widget: epochSeconds` on an integer.
49 EpochDate, ///< A calendar date as int64 days since the epoch: `x-widget: epochDays` on an integer.
50 Slider, ///< A number on a track (`x-widget: slider` with `x-min`/`x-max`).
51 Array, ///< A list of scalars, entered comma-separated.
52 Object, ///< A nested object.
53 ObjectArray ///< A list of nested objects.
54};
55
56/// @brief The stable name of a field kind (what `Overrides::byKind` documents and tests print).
57/// @param kind The kind.
58/// @return `"text"`, `"multiline"`, `"integer"`, `"number"`, `"decimal"`, `"quantity"`, `"boolean"`,
59/// `"enum"`, `"choice"`, `"datetime"`, `"date"`, `"epochDateTime"`, `"epochDate"`, `"slider"`, `"array"`,
60/// `"object"` or `"objectArray"`.
61[[nodiscard]] constexpr std::string_view fieldKindName(FieldKind kind) noexcept {
62 switch (kind) {
63 case FieldKind::Text:
64 return "text";
66 return "multiline";
68 return "integer";
70 return "number";
72 return "decimal";
74 return "quantity";
76 return "boolean";
77 case FieldKind::Enum:
78 return "enum";
80 return "choice";
82 return "datetime";
83 case FieldKind::Date:
84 return "date";
86 return "epochDateTime";
88 return "epochDate";
90 return "slider";
92 return "array";
94 return "object";
96 return "objectArray";
97 default:
98 return "text";
99 }
100}
101
102/// @brief When a form submits.
103enum class SubmitMode : std::uint8_t {
104 Automatic, ///< Whenever it becomes ready with a changed body (no `x-submitMode`).
105 Explicit ///< Only on `FormSession::submit()` (`x-submitMode: explicit`).
106};
107
108/// @brief How many object levels below the action a schema is described; a deeper member is
109/// `FieldSpec::truncated`.
110inline constexpr std::size_t kMaxNestingDepth = 4;
111
112/// @brief One unit a Quantity can be entered in; the first of a field's units is canonical.
114 /// @brief The unit id (`ExtUnits.unitAscii`, or the alternative's `id`).
115 std::string id;
116 /// @brief What a selector shows.
117 std::string display;
118 /// @brief Fraction digits accepted in this unit.
119 std::uint32_t decimals = 0;
120 /// @brief Numerator of the exact unit-to-canonical ratio.
121 std::int64_t num = 1;
122 /// @brief Denominator of the exact unit-to-canonical ratio.
123 std::int64_t den = 1;
124 /// @brief Member-wise equality.
125 /// @param other The other unit.
126 /// @return `true` when every member matches.
127 bool operator==(UnitOption const& other) const = default;
128};
129
130/// @brief One selectable value of an Enum or a Choice.
132 /// @brief The value as the JSON literal the body carries (`"\"Viewer\""`, `"9007199254740993"`).
133 std::string valueJson;
134 /// @brief What the user sees.
135 std::string label;
136 /// @brief Member-wise equality.
137 /// @param other The other option.
138 /// @return `true` when value and label match.
139 bool operator==(ChoiceOption const& other) const = default;
140};
141
142/// @brief Where a Choice's options come from (`x-optionsAction` and its companions).
144 /// @brief The action whose result rows are the options.
145 std::string optionsAction;
146 /// @brief The row member submitted as the value (`x-optionValue`).
147 std::string valueField = "id";
148 /// @brief The row member shown (`x-optionLabel`).
149 std::string labelField = "name";
150 /// @brief Sibling wire names whose values parameterise the options request (`x-optionsDependsOn`).
151 std::vector<std::string> dependsOn;
152 /// @brief Member-wise equality.
153 /// @param other The other descriptor.
154 /// @return `true` when every member matches.
155 bool operator==(ChoiceSpec const& other) const = default;
156};
157
158/// @brief Everything the engine knows about one field, from its schema property.
159// NOLINTNEXTLINE(misc-no-recursion): a field's members are fields, so copying one copies them.
160struct FieldSpec {
161 /// @brief The wire name.
162 std::string name;
163 /// @brief The path from the action: `name`, `parent.name`, or `rows[].name` for a collection's member.
164 std::string path;
165 /// @brief The control and encoder.
167 /// @brief The first declared JSON type (`"integer"`, …), empty when the property declares none.
168 std::string jsonType;
169 /// @brief The schema's title, or the wire name.
170 std::string title;
171 /// @brief The schema's `description`.
172 std::string help;
173 /// @brief `x-placeholder`.
174 std::string placeholder;
175 /// @brief `x-i18nKey`: the explicit message-key stem, empty when none is declared.
176 std::string i18nKey;
177 /// @brief Named in the containing object's `required`.
178 bool required = false;
179 /// @brief `x-blankAs` (Text and Multiline only): whether a cleared field submits `""`.
181 /// @brief `x-readonly`.
182 bool readOnly = false;
183 /// @brief `x-hidden`.
184 bool hidden = false;
185 /// @brief `x-computed.inputs`: the members a computed field derives from.
186 std::vector<std::string> computedInputs;
187 /// @brief `minimum`, as canonical decimal text.
188 std::optional<std::string> minimum;
189 /// @brief `maximum`, as canonical decimal text.
190 std::optional<std::string> maximum;
191 /// @brief A positive `multipleOf`, as canonical decimal text.
192 std::optional<std::string> multipleOf;
193 /// @brief `x-exactMinimum`: an integer bound's exact digits.
194 std::optional<std::string> exactMinimum;
195 /// @brief `x-exactMaximum`: an integer bound's exact digits.
196 std::optional<std::string> exactMaximum;
197 /// @brief `x-minimum`: a per-instance lower bound (`InstanceConstraints::decorate`).
198 std::optional<math::Rational> instanceMinimum;
199 /// @brief `x-maximum`: a per-instance upper bound.
200 std::optional<math::Rational> instanceMaximum;
201 /// @brief `x-decimalPlaces`: the declared precision of a Decimal or Quantity.
202 std::optional<std::uint32_t> decimalPlaces;
203 /// @brief `x-displayDecimals`: a plain Number's entry and display precision.
204 std::optional<std::uint32_t> displayDecimals;
205 /// @brief The display unit (`ExtUnits.unitUnicode`, else `unitAscii`), empty when none.
206 std::string unit;
207 /// @brief `ExtUnits.unitAscii`.
208 std::string unitAscii;
209 /// @brief A Decimal's or Quantity's entry units: the canonical one first, then `x-unitAlternatives`.
210 std::vector<UnitOption> units;
211 /// @brief The index into `units` a draft starts in (and returns to on prefill and reset): 0, the canonical
212 /// unit, unless a field overlay's `unit` names another.
213 std::size_t entryUnit = 0;
214 /// @brief An Enum's options, in schema order.
215 std::vector<ChoiceOption> enumOptions;
216 /// @brief A Choice's options source.
217 std::optional<ChoiceSpec> choice;
218 /// @brief `x-section`: the index of the `x-layout` group the field sits in.
219 std::optional<std::size_t> group;
220 /// @brief `x-colspan`: grid columns the field spans (at least 1).
221 int colspan = 1;
222 /// @brief `x-widget`, empty when none.
223 std::string widget;
224 /// @brief A Slider's track minimum (`x-min`).
225 std::int64_t sliderMin = 0;
226 /// @brief A Slider's track maximum (`x-max`).
227 std::int64_t sliderMax = 100;
228 /// @brief A Slider's increment (`x-step`, at least 1).
229 std::int64_t sliderStep = 1;
230 /// @brief An Array's item JSON type (`"string"` when undeclared).
231 std::string itemType;
232 /// @brief An Object's members, or an ObjectArray's element members, in `x-order`.
233 std::vector<FieldSpec> children;
234 /// @brief An Object or ObjectArray whose members are not described: its `$ref` is already on
235 /// the path, it is deeper than `kMaxNestingDepth`, or its schema states no members.
236 bool truncated = false;
237 /// @brief A `string` member whose schema says `contentEncoding: base64`: its wire value is a file's bytes, which a
238 /// document moves inline only under the session's inline cap.
239 bool bytes = false;
240};
241
242/// @brief What a document (or an application) changes about one top-level field before the session is built: a
243/// `form` node's `fields` entry (spec 6 §10). An unset member keeps the schema's.
245 /// @brief Removes the member from the form and from its body.
246 bool omit = false;
247 /// @brief Keeps the member in the body and out of the view (`x-hidden`).
248 bool hidden = false;
249 /// @brief Replaces the label (`title`).
250 std::optional<std::string> label;
251 /// @brief The unit a Quantity is entered in: the id of one of its units (its canonical unit or an alternative).
252 /// On a field without units, the unit shown beside it.
253 std::optional<std::string> unit;
254 /// @brief The places a Decimal or Quantity is entered with in its entry unit, or a Number's display decimals.
255 /// The wire value keeps the schema's places.
256 std::optional<std::uint32_t> decimals;
257 /// @brief Replaces `x-widget`, so the kind is read again (`textarea`, `radio`, `slider`, `epochDays`, …).
258 std::optional<std::string> widget;
259 /// @brief Replaces `x-blankAs`.
260 std::optional<BlankAs> blankAs;
261};
262
263/// @brief The overlays of a form's fields, by wire name.
264using FieldOverlays = std::map<std::string, FieldOverlay, std::less<>>;
265
266/// @brief One `x-layout` group, or the implicit group of the fields no group names.
268 /// @brief The group's title (empty for the implicit group).
269 std::string title;
270 /// @brief Section, tab or accordion; the implicit group is a `Section` with `implicit` set.
272 /// @brief The wire names of the fields in the group, in `x-order`.
273 std::vector<std::string> fields;
274 /// @brief The trailing group of fields no declared group names (or every field, without `x-layout`).
275 bool implicit = false;
276};
277
278namespace detail::engine {
279
280/// @brief Whether @p range holds @p value.
281/// @param range A range.
282/// @param value The value to look for.
283/// @return `true` when an element equals @p value.
284[[nodiscard]] inline bool contains(auto const& range, auto const& value) {
285 return std::ranges::find(range, value) != std::ranges::end(range);
286}
287
288/// @brief Bounds-checked element access for a span, which has no `at()` before C++26.
289/// @tparam T The element type.
290/// @param items The span.
291/// @param index The position.
292/// @return The element at @p index.
293/// @throws std::out_of_range when @p index is not below `items.size()`.
294template <class T>
295[[nodiscard]] T& elementAt(std::span<T> items, std::size_t index) {
296 if (index >= items.size()) {
297 throw std::out_of_range{"morph::forms: index " + std::to_string(index) + " past a span of " +
298 std::to_string(items.size())};
299 }
300 return *std::next(items.begin(), static_cast<std::ptrdiff_t>(index));
301}
302
303/// @brief Reads property schemas: resolves `$ref` into `$defs`, collapses nullable
304/// `anyOf`/`oneOf`, and describes an object's properties as `FieldSpec`s.
305class SchemaReader {
306public:
307 /// @param root The whole schema document. Borrowed: it must outlive the reader.
308 explicit SchemaReader(Json const& root) : _defs{member(root, "$defs")} {}
309
310 /// @brief Follows a `$ref` into `$defs`; the property's own keys win over the definition's.
311 /// @param prop A property schema.
312 /// @return The merged object (`{}` for a non-object).
313 [[nodiscard]] Json resolveRef(Json const& prop) const {
314 if (!prop.is_object()) {
315 return emptyObject();
316 }
317 auto const* const ref = stringAt(prop, "$ref");
318 if (ref == nullptr) {
319 return prop;
320 }
321 std::string_view name{*ref};
322 name = name.substr(name.rfind('/') + 1);
323 Json merged = emptyObject();
324 if (Json const* const def = _defs == nullptr ? nullptr : member(*_defs, name);
325 def != nullptr && def->is_object()) {
326 merged = *def;
327 }
328 for (auto const& [key, value] : prop.get_object()) {
329 merged.get_object().insert_or_assign(key, value);
330 }
331 return merged;
332 }
333
334 /// @brief Resolves `$ref`, then collapses a nullable `anyOf`/`oneOf` onto its first non-null
335 /// branch; the outer keys win, and the branches' `anyOf`/`oneOf`/`const` are dropped.
336 /// @param prop A property schema.
337 /// @return The resolved property.
338 [[nodiscard]] Json resolveProp(Json const& prop) const {
339 Json resolved = resolveRef(prop);
340 Json const* branches = member(resolved, "anyOf");
341 if (branches == nullptr || !branches->is_array()) {
342 branches = member(resolved, "oneOf");
343 }
344 if (branches == nullptr || !branches->is_array()) {
345 return resolved;
346 }
347 auto const dropped = [](std::string_view key) { return key == "anyOf" || key == "oneOf" || key == "const"; };
348 for (Json const& raw : branches->get_array()) {
349 Json const branch = resolveRef(raw);
350 if (auto const* const type = stringAt(branch, "type"); type != nullptr && *type == "null") {
351 continue;
352 }
353 Json merged = emptyObject();
354 for (auto const& [key, value] : branch.get_object()) {
355 if (member(resolved, key) == nullptr && !dropped(key)) {
356 merged.get_object().insert_or_assign(key, value);
357 }
358 }
359 for (auto const& [key, value] : resolved.get_object()) {
360 if (!dropped(key)) {
361 merged.get_object().insert_or_assign(key, value);
362 }
363 }
364 return merged;
365 }
366 return resolved;
367 }
368
369 /// @brief Describes an object schema's properties, in `x-order` (stable for ties).
370 /// @param objectSchema The object schema.
371 /// @param depth 0 for the action, one more per nested level.
372 /// @param pathPrefix Prefix of every member's path (`""`, `"parent."`, `"rows[]."`).
373 /// @param refChain The `$defs` references on the path here.
374 /// @return The fields, or the error naming the malformed property.
375 // NOLINTNEXTLINE(misc-no-recursion) -- an object's members are described by the same walk, to kMaxNestingDepth
376 [[nodiscard]] std::expected<std::vector<FieldSpec>, SchemaError> describe(
377 Json const& objectSchema, std::size_t depth, std::string const& pathPrefix,
378 std::vector<std::string> const& refChain) const {
379 std::vector<FieldSpec> fields;
380 Json const* const props = member(objectSchema, "properties");
381 if (props == nullptr) {
382 return fields;
383 }
384 if (!props->is_object()) {
385 return std::unexpected(
386 SchemaError{.path = pathPrefix + "properties", .message = "\"properties\" must be an object"});
387 }
388 std::vector<std::string> required;
389 if (Json const* const list = member(objectSchema, "required"); list != nullptr && list->is_array()) {
390 for (Json const& entry : list->get_array()) {
391 if (auto const* const text = entry.get_if<std::string>()) {
392 required.push_back(*text);
393 }
394 }
395 }
396 std::vector<std::pair<std::string, std::uint64_t>> order;
397 for (auto const& [name, prop] : props->get_object()) {
398 order.emplace_back(name, unsignedOf(member(prop, "x-order")).value_or(0));
399 }
400 std::ranges::stable_sort(order, {}, &std::pair<std::string, std::uint64_t>::second);
401 for (auto const& [name, ignoredOrder] : order) {
402 static_cast<void>(ignoredOrder);
403 Json const& raw = *member(*props, name);
404 if (!raw.is_object()) {
405 std::string path{"properties."};
406 path += pathPrefix;
407 path += name;
408 return std::unexpected(
409 SchemaError{.path = std::move(path), .message = "a property schema must be a JSON object"});
410 }
411 auto field = describeOne(raw, name, depth, pathPrefix, refChain, contains(required, name));
412 if (!field) {
413 return std::unexpected(field.error());
414 }
415 fields.push_back(*std::move(field));
416 }
417 return fields;
418 }
419
420 /// @brief A non-negative integer member, also from an integral double.
421 /// @param value The member, or null.
422 /// @return The value, or `std::nullopt`.
423 [[nodiscard]] static std::optional<std::uint64_t> unsignedOf(Json const* value) {
424 if (value == nullptr) {
425 return std::nullopt;
426 }
427 if (auto const* const asUnsigned = value->get_if<std::uint64_t>()) {
428 return *asUnsigned;
429 }
430 if (auto const* const asSigned = value->get_if<std::int64_t>(); asSigned != nullptr && *asSigned >= 0) {
431 return static_cast<std::uint64_t>(*asSigned);
432 }
433 if (auto const* const asDouble = value->get_if<double>();
434 asDouble != nullptr && *asDouble >= 0 && *asDouble == std::floor(*asDouble) && *asDouble < 1.8e19) {
435 return static_cast<std::uint64_t>(*asDouble);
436 }
437 return std::nullopt;
438 }
439
440 /// @brief A signed integer member; a double is truncated toward zero.
441 /// @param value The member, or null.
442 /// @return The value, or `std::nullopt`.
443 [[nodiscard]] static std::optional<std::int64_t> signedOf(Json const* value) {
444 if (value == nullptr) {
445 return std::nullopt;
446 }
447 if (auto const* const asSigned = value->get_if<std::int64_t>()) {
448 return *asSigned;
449 }
450 if (auto const* const asUnsigned = value->get_if<std::uint64_t>();
451 asUnsigned != nullptr && std::in_range<std::int64_t>(*asUnsigned)) {
452 return static_cast<std::int64_t>(*asUnsigned);
453 }
454 if (auto const* const asDouble = value->get_if<double>();
455 asDouble != nullptr && std::isfinite(*asDouble) && std::abs(*asDouble) < 9.2e18) {
456 return static_cast<std::int64_t>(*asDouble);
457 }
458 return std::nullopt;
459 }
460
461private:
462 [[nodiscard]] static std::vector<std::string> jsonTypes(Json const& prop) {
463 std::vector<std::string> types;
464 Json const* const type = member(prop, "type");
465 if (type == nullptr) {
466 return types;
467 }
468 if (auto const* const one = type->get_if<std::string>()) {
469 types.push_back(*one);
470 } else if (type->is_array()) {
471 for (Json const& entry : type->get_array()) {
472 if (auto const* const text = entry.get_if<std::string>()) {
473 types.push_back(*text);
474 }
475 }
476 }
477 return types;
478 }
479
480 /// The key from the raw property first, then from the resolved one (the QML reader's order).
481 [[nodiscard]] static Json const* either(Json const& raw, Json const& resolved, std::string_view key) {
482 Json const* const own = member(raw, key);
483 return own != nullptr ? own : member(resolved, key);
484 }
485
486 [[nodiscard]] static std::string textOf(Json const& raw, Json const& resolved, std::string_view key) {
487 Json const* const value = either(raw, resolved, key);
488 auto const* const text = value == nullptr ? nullptr : value->get_if<std::string>();
489 return text == nullptr ? std::string{} : *text;
490 }
491
492 [[nodiscard]] static bool flagOf(Json const& raw, Json const& resolved, std::string_view key) {
493 Json const* const value = either(raw, resolved, key);
494 auto const* const flag = value == nullptr ? nullptr : value->get_if<bool>();
495 return flag != nullptr && *flag;
496 }
497
498 [[nodiscard]] static std::string refNameOf(Json const& raw) {
499 if (auto const* const ref = stringAt(raw, "$ref")) {
500 return *ref;
501 }
502 for (std::string_view const key : {"anyOf", "oneOf"}) {
503 if (Json const* const branches = member(raw, key); branches != nullptr && branches->is_array()) {
504 for (Json const& branch : branches->get_array()) {
505 if (auto const* const ref = stringAt(branch, "$ref")) {
506 return *ref;
507 }
508 }
509 }
510 }
511 return {};
512 }
513
514 // NOLINTNEXTLINE(misc-no-recursion) -- one nested level of branches, bounded by `nested`
515 [[nodiscard]] std::optional<std::vector<ChoiceOption>> constBranches(Json const& prop, bool nested) const {
516 Json const* branches = member(prop, "anyOf");
517 if (branches == nullptr || !branches->is_array()) {
518 branches = member(prop, "oneOf");
519 }
520 if (branches == nullptr || !branches->is_array()) {
521 return std::nullopt;
522 }
523 std::vector<ChoiceOption> rows;
524 for (Json const& raw : branches->get_array()) {
525 Json const branch = resolveRef(raw);
526 if (auto const* const type = stringAt(branch, "type"); type != nullptr && *type == "null") {
527 continue;
528 }
529 if (Json const* const literal = member(branch, "const")) {
530 auto const* const title = stringAt(branch, "title");
531 rows.push_back(ChoiceOption{.valueJson = toJson(*literal),
532 .label = title != nullptr ? *title : displayText(*literal)});
533 continue;
534 }
535 auto inner = nested ? constBranches(branch, false) : std::nullopt;
536 if (!inner || inner->empty()) {
537 return std::nullopt;
538 }
539 rows.insert(rows.end(), inner->begin(), inner->end());
540 }
541 return rows;
542 }
543
544 [[nodiscard]] std::vector<ChoiceOption> enumChoices(Json const& raw) const {
545 Json const prop = resolveRef(raw);
546 if (Json const* const literals = member(prop, "enum"); literals != nullptr && literals->is_array()) {
547 std::vector<ChoiceOption> rows;
548 for (Json const& literal : literals->get_array()) {
549 if (!literal.is_null()) {
550 rows.push_back(ChoiceOption{.valueJson = toJson(literal), .label = displayText(literal)});
551 }
552 }
553 return rows;
554 }
555 return constBranches(prop, true).value_or(std::vector<ChoiceOption>{});
556 }
557
558 [[nodiscard]] static std::optional<math::Rational> rationalOf(Json const* node) {
559 if (node == nullptr) {
560 return std::nullopt;
561 }
562 auto const num = signedOf(member(*node, "num"));
563 auto const den = signedOf(member(*node, "den"));
564 if (!num || !den || *den == 0) {
565 return std::nullopt;
566 }
567 auto const places = unsignedOf(member(*node, "dp")).value_or(0);
568 return math::Rational{
569 math::Numerator{*num}, math::Denominator{*den},
570 math::DecimalPlaces{static_cast<std::uint32_t>(std::min<std::uint64_t>(places, math::kMaxDecimalPlaces))}};
571 }
572
573 // The other half of describe's walk, with one branch per schema keyword a field reads.
574 // NOLINTNEXTLINE(misc-no-recursion,readability-function-cognitive-complexity)
575 [[nodiscard]] std::expected<FieldSpec, SchemaError> describeOne(Json const& raw, std::string const& name,
576 std::size_t depth, std::string const& pathPrefix,
577 std::vector<std::string> const& refChain,
578 bool required) const {
579 Json const prop = resolveProp(raw);
580 std::vector<std::string> const types = jsonTypes(prop);
581 auto const hasType = [&](std::string_view type) { return contains(types, type); };
582 FieldSpec spec{};
583 spec.name = name;
584 spec.path = pathPrefix + name;
585 spec.jsonType = types.empty() ? std::string{} : types.front();
586 spec.title = textOf(raw, prop, "title");
587 if (spec.title.empty()) {
588 spec.title = name;
589 }
590 spec.help = textOf(prop, prop, "description");
591 spec.placeholder = textOf(raw, prop, "x-placeholder");
592 spec.i18nKey = textOf(raw, prop, "x-i18nKey");
593 spec.required = required;
594 spec.readOnly = flagOf(raw, prop, "x-readonly");
595 spec.hidden = flagOf(raw, prop, "x-hidden");
596 spec.widget = textOf(raw, prop, "x-widget");
597 if (Json const* const computed = either(raw, prop, "x-computed")) {
598 if (Json const* const inputs = member(*computed, "inputs"); inputs != nullptr && inputs->is_array()) {
599 for (Json const& input : inputs->get_array()) {
600 if (auto const* const text = input.get_if<std::string>()) {
601 spec.computedInputs.push_back(*text);
602 }
603 }
604 }
605 }
606 if (Json const* const minimum = member(prop, "minimum")) {
607 spec.minimum = numberText(*minimum);
608 }
609 if (Json const* const maximum = member(prop, "maximum")) {
610 spec.maximum = numberText(*maximum);
611 }
612 if (Json const* const step = member(prop, "multipleOf")) {
613 auto text = numberText(*step);
614 if (text && compareDecimal(*text, "0") == std::strong_ordering::greater) {
615 spec.multipleOf = std::move(text);
616 }
617 }
618 if (auto const* const exact = stringAt(prop, "x-exactMinimum");
619 exact != nullptr && isCanonicalInteger(*exact)) {
620 spec.exactMinimum = *exact;
621 }
622 if (auto const* const exact = stringAt(prop, "x-exactMaximum");
623 exact != nullptr && isCanonicalInteger(*exact)) {
624 spec.exactMaximum = *exact;
625 }
626 spec.instanceMinimum = rationalOf(either(raw, prop, "x-minimum"));
627 spec.instanceMaximum = rationalOf(either(raw, prop, "x-maximum"));
628 if (auto const places = unsignedOf(either(raw, prop, "x-decimalPlaces"))) {
629 spec.decimalPlaces = static_cast<std::uint32_t>(std::min<std::uint64_t>(*places, math::kMaxDecimalPlaces));
630 }
631 if (Json const* const units = member(prop, "ExtUnits")) {
632 auto const* const ascii = stringAt(*units, "unitAscii");
633 auto const* const unicode = stringAt(*units, "unitUnicode");
634 spec.unitAscii = ascii != nullptr ? *ascii : std::string{};
635 spec.unit = unicode != nullptr && !unicode->empty() ? *unicode : spec.unitAscii;
636 }
637 if (auto const section = unsignedOf(either(raw, prop, "x-section"))) {
638 spec.group = static_cast<std::size_t>(*section);
639 }
640 spec.colspan =
641 static_cast<int>(std::clamp<std::int64_t>(signedOf(either(raw, prop, "x-colspan")).value_or(1), 1, 1024));
642
643 auto const* const optionsAction = [&] -> std::string const* {
644 Json const* const value = either(raw, prop, "x-optionsAction");
645 return value == nullptr ? nullptr : value->get_if<std::string>();
646 }();
647 std::vector<ChoiceOption> enumRows = optionsAction != nullptr ? std::vector<ChoiceOption>{} : enumChoices(raw);
648 auto const format = textOf(prop, prop, "format");
649 auto const sliderMin = signedOf(either(raw, prop, "x-min"));
650 auto const sliderMax = signedOf(either(raw, prop, "x-max"));
651 std::string const refName = refNameOf(raw);
652 bool const describesNested = depth < kMaxNestingDepth && (refName.empty() || !contains(refChain, refName));
653 std::vector<std::string> nestedChain = refChain;
654 if (!refName.empty()) {
655 nestedChain.push_back(refName);
656 }
657
658 if (hasType("array")) {
659 Json const* const rawItems = member(prop, "items");
660 Json const items = rawItems == nullptr ? emptyObject() : resolveProp(*rawItems);
661 std::vector<std::string> const itemTypes = jsonTypes(items);
662 if (contains(itemTypes, "object") || contains(itemTypes, "array")) {
663 spec.kind = FieldKind::ObjectArray;
664 std::string const itemRef = rawItems == nullptr ? std::string{} : refNameOf(*rawItems);
665 bool const itemsDescribed = depth < kMaxNestingDepth && member(items, "properties") != nullptr &&
666 (itemRef.empty() || !contains(refChain, itemRef));
667 if (itemsDescribed) {
668 std::vector<std::string> itemChain = refChain;
669 if (!itemRef.empty()) {
670 itemChain.push_back(itemRef);
671 }
672 auto children = describe(items, depth + 1, spec.path + "[].", itemChain);
673 if (!children) {
674 return std::unexpected(children.error());
675 }
676 spec.children = *std::move(children);
677 } else {
678 spec.truncated = true;
679 }
680 } else {
681 spec.kind = FieldKind::Array;
682 spec.itemType = itemTypes.empty() ? std::string{"string"} : itemTypes.front();
683 }
684 } else if (!enumRows.empty()) {
685 spec.kind = FieldKind::Enum;
686 spec.enumOptions = std::move(enumRows);
687 } else if (optionsAction != nullptr) {
688 spec.kind = FieldKind::Choice;
689 ChoiceSpec choice{};
690 choice.optionsAction = *optionsAction;
691 if (auto value = textOf(raw, prop, "x-optionValue"); !value.empty()) {
692 choice.valueField = std::move(value);
693 }
694 if (auto label = textOf(raw, prop, "x-optionLabel"); !label.empty()) {
695 choice.labelField = std::move(label);
696 }
697 if (Json const* const parents = either(raw, prop, "x-optionsDependsOn");
698 parents != nullptr && parents->is_array()) {
699 for (Json const& parent : parents->get_array()) {
700 if (auto const* const text = parent.get_if<std::string>()) {
701 choice.dependsOn.push_back(*text);
702 }
703 }
704 }
705 spec.choice = std::move(choice);
706 } else if (format == "date-time") {
707 spec.kind = FieldKind::DateTime;
708 } else if (format == "date") {
709 spec.kind = FieldKind::Date;
710 } else if (spec.decimalPlaces.has_value()) {
711 spec.kind = member(prop, "ExtUnits") != nullptr ? FieldKind::Quantity : FieldKind::Decimal;
712 spec.units.push_back(UnitOption{
713 .id = spec.unitAscii, .display = spec.unit, .decimals = *spec.decimalPlaces, .num = 1, .den = 1});
714 if (Json const* const alternatives = either(raw, prop, "x-unitAlternatives");
715 spec.kind == FieldKind::Quantity && alternatives != nullptr && alternatives->is_array()) {
716 for (Json const& alternative : alternatives->get_array()) {
717 UnitOption option{};
718 option.id = textOf(alternative, alternative, "id");
719 option.display = textOf(alternative, alternative, "display");
720 if (option.display.empty()) {
721 option.display = option.id;
722 }
723 option.decimals = static_cast<std::uint32_t>(std::min<std::uint64_t>(
724 unsignedOf(member(alternative, "decimals")).value_or(0), math::kMaxDecimalPlaces));
725 option.num = signedOf(member(alternative, "num")).value_or(1);
726 option.den = signedOf(member(alternative, "den")).value_or(1);
727 if (option.num > 0 && option.den > 0) {
728 spec.units.push_back(std::move(option));
729 }
730 }
731 }
732 } else if ((hasType("integer") || hasType("number")) && spec.widget == "slider" && sliderMin && sliderMax) {
733 spec.kind = FieldKind::Slider;
734 spec.sliderMin = *sliderMin;
735 spec.sliderMax = *sliderMax;
736 spec.sliderStep = std::max<std::int64_t>(1, signedOf(either(raw, prop, "x-step")).value_or(1));
737 } else if (hasType("integer") && spec.widget == "epochSeconds") {
738 spec.kind = FieldKind::EpochDateTime;
739 } else if (hasType("integer") && spec.widget == "epochDays") {
740 spec.kind = FieldKind::EpochDate;
741 } else if (hasType("integer")) {
742 spec.kind = FieldKind::Integer;
743 } else if (hasType("boolean")) {
744 spec.kind = FieldKind::Boolean;
745 } else if (hasType("number")) {
746 spec.kind = FieldKind::Number;
747 if (auto const decimals = unsignedOf(either(raw, prop, "x-displayDecimals"))) {
748 spec.displayDecimals =
749 static_cast<std::uint32_t>(std::min<std::uint64_t>(*decimals, math::kMaxDecimalPlaces));
750 }
751 } else if (hasType("object")) {
752 spec.kind = FieldKind::Object;
753 if (describesNested && member(prop, "properties") != nullptr) {
754 auto children = describe(prop, depth + 1, spec.path + ".", nestedChain);
755 if (!children) {
756 return std::unexpected(children.error());
757 }
758 spec.children = *std::move(children);
759 } else {
760 spec.truncated = true;
761 }
762 } else if (spec.widget == "textarea") {
763 spec.kind = FieldKind::Multiline;
764 } else {
765 spec.kind = FieldKind::Text;
766 }
767 if ((spec.kind == FieldKind::Text || spec.kind == FieldKind::Multiline) &&
768 textOf(raw, prop, "x-blankAs") == "empty") {
769 spec.blankAs = BlankAs::Empty;
770 }
771 spec.bytes = spec.jsonType == "string" && textOf(raw, prop, "contentEncoding") == "base64";
772 return spec;
773 }
774
775 Json const* _defs;
776};
777
778} // namespace detail::engine
779
780/// @brief The immutable runtime model of one action's form.
781///
782/// Built from schema JSON by `fromSchema`; `forAction<A>()` (which the typed facade `Form<A>`
783/// uses) reads `schemaJson<A>()` through the same reader, so a typed and a runtime form of one
784/// action are the same model.
785class FormModel {
786public:
787 /// @brief Reads a schema document, with the overlays a document or an application states for its fields.
788 ///
789 /// `omit`, `hidden`, `label`, `widget` and `blankAs` are applied to the schema before it is read, so a widget
790 /// overlay chooses the kind exactly as the schema's own `x-widget` would; `unit` and `decimals` are applied to
791 /// the read field. An overlay naming a field the schema does not have is a `SchemaError`, as is a `unit` that is
792 /// not one of a Quantity's units.
793 /// @param actionType The action type the form submits (the registered type id).
794 /// @param schemaJson The schema, in the vocabulary `schemaJson<A>()` emits.
795 /// @param overlays The overlays of top-level fields, by wire name.
796 /// @return The model, or the error naming where the document is malformed.
797 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): a type id and a JSON document; swapped, neither reads.
798 [[nodiscard]] static std::expected<FormModel, SchemaError> fromSchema(std::string_view actionType,
799 std::string_view schemaJson,
800 FieldOverlays const& overlays = {}) {
801 using detail::engine::Json;
802 auto parsedDom = detail::engine::parseJson(schemaJson);
803 if (!parsedDom) {
804 return std::unexpected(SchemaError{.path = "", .message = "the schema is not valid JSON"});
805 }
806 if (!parsedDom->is_object()) {
807 return std::unexpected(SchemaError{.path = "", .message = "the schema is not a JSON object"});
808 }
809 if (auto applied = applyOverlays(*parsedDom, overlays); !applied) {
810 return std::unexpected(applied.error());
811 }
812 Json const* const dom = &*parsedDom;
813 FormModel model{};
814 model._actionType = actionType;
815 auto const* const title = detail::engine::stringAt(*dom, "title");
816 model._title = title != nullptr ? *title : std::string{actionType};
817 detail::engine::SchemaReader const reader{*dom};
818 auto fields = reader.describe(*dom, 0, "", {});
819 if (!fields) {
820 return std::unexpected(fields.error());
821 }
822 model._fields = *std::move(fields);
823 if (auto groups = readGroups(*dom, model._fields); groups) {
824 model._groups = *std::move(groups);
825 } else {
826 return std::unexpected(groups.error());
827 }
828 if (Json const* const rules = detail::engine::member(*dom, "x-rules")) {
829 if (!rules->is_array()) {
830 return std::unexpected(SchemaError{.path = "x-rules", .message = "\"x-rules\" must be an array"});
831 }
832 std::size_t index = 0;
833 for (Json const& node : rules->get_array()) {
834 auto parsed = RuleExpr::parse(node, "x-rules[" + std::to_string(index++) + "]");
835 if (!parsed) {
836 return std::unexpected(parsed.error());
837 }
838 model._rules.push_back(*std::move(parsed));
839 }
840 }
841 auto const* const mode = detail::engine::stringAt(*dom, "x-submitMode");
842 model._submitMode = (mode != nullptr && *mode == "explicit") ? SubmitMode::Explicit : SubmitMode::Automatic;
843 for (auto const& [name, overlay] : overlays) {
844 if (overlay.omit) {
845 continue;
846 }
847 auto const found = std::ranges::find(model._fields, name, &FieldSpec::name);
848 if (auto applied = applyUnitOverlay(*found, overlay); !applied) {
849 return std::unexpected(applied.error());
850 }
851 }
852 return model;
853 }
854
855 /// @brief The same model with another submit mode: a document's `submit` choosing over `x-submitMode`.
856 /// @param mode The mode.
857 /// @return The model.
858 [[nodiscard]] FormModel withSubmitMode(SubmitMode mode) const {
859 FormModel copy = *this;
860 copy._submitMode = mode;
861 return copy;
862 }
863
864 /// @brief The model of a registered action's own form: `fromSchema` over
865 /// `ActionTraits<A>::typeId()` and `schemaJson<A>()`.
866 ///
867 /// Declared here and defined in `forms/engine/typed_form.hpp`, the one engine header that includes the
868 /// schema emitters; include that header to call it. The schema is compiled from `A`, so a reader error is a
869 /// defect in the emitter or the engine, never bad input: it throws rather than returning an `expected` every
870 /// caller would have to unwrap.
871 /// @tparam A The action type (registered with `BRIDGE_REGISTER_ACTION` or declared with
872 /// `BRIDGE_DECLARE_ACTION`).
873 /// @return The model.
874 /// @throws std::logic_error naming the action id and the error's path and message when the
875 /// engine cannot read the action's schema.
876 template <class A>
877 [[nodiscard]] static FormModel forAction();
878
879 /// @brief The action type the form submits.
880 /// @return The type id passed to `fromSchema`.
881 [[nodiscard]] std::string_view actionType() const noexcept { return _actionType; }
882
883 /// @brief The schema's top-level `title`, or the action type.
884 /// @return The title.
885 [[nodiscard]] std::string_view title() const noexcept { return _title; }
886
887 /// @brief The top-level fields, in `x-order`.
888 /// @return The fields.
889 [[nodiscard]] std::span<FieldSpec const> fields() const noexcept { return _fields; }
890
891 /// @brief The layout groups: the declared `x-layout` groups, then an implicit group of the
892 /// fields none names; without `x-layout`, one implicit group of every field.
893 /// @return The groups.
894 [[nodiscard]] std::span<FieldGroupSpec const> groups() const noexcept { return _groups; }
895
896 /// @brief The parsed `x-rules`, in declaration order.
897 /// @return The rules.
898 [[nodiscard]] std::span<RuleExpr const> rules() const noexcept { return _rules; }
899
900 /// @brief When the form submits.
901 /// @return `Explicit` for `x-submitMode: explicit`, else `Automatic`.
902 [[nodiscard]] SubmitMode submitMode() const noexcept { return _submitMode; }
903
904 /// @brief A top-level field by wire name.
905 /// @param name The wire name.
906 /// @return The field, or null.
907 [[nodiscard]] FieldSpec const* find(std::string_view name) const noexcept {
908 auto const found = std::ranges::find(_fields, name, &FieldSpec::name);
909 return found == _fields.end() ? nullptr : &*found;
910 }
911
912private:
913 FormModel() = default;
914
915 [[nodiscard]] static GroupKind groupKindOf(std::string const* kind) {
916 if (kind != nullptr && *kind == "tab") {
917 return GroupKind::Tab;
918 }
919 if (kind != nullptr && *kind == "accordion") {
920 return GroupKind::Accordion;
921 }
922 return GroupKind::Section;
923 }
924
925 /// Writes the schema-level overlays into the schema's properties: `omit` removes the property
926 /// (a `required` entry naming it marks nothing); the rest set the keywords the reader reads.
927 // NOLINTNEXTLINE(readability-function-cognitive-complexity): one case per overlay key.
928 [[nodiscard]] static std::expected<void, SchemaError> applyOverlays(detail::engine::Json& dom,
929 FieldOverlays const& overlays) {
930 using detail::engine::Json;
931 if (overlays.empty()) {
932 return {};
933 }
934 auto* const properties = dom.get_object().contains("properties")
935 ? dom.get_object().find("properties")->second.get_if<Json::object_t>()
936 : nullptr;
937 for (auto const& [name, overlay] : overlays) {
938 if (properties == nullptr || !properties->contains(name)) {
939 return std::unexpected(SchemaError{.path = "fields." + name,
940 .message = "the overlay names a field the schema does not have"});
941 }
942 Json& property = properties->find(name)->second;
943 if (overlay.omit) {
944 // Its `required` entry may stay: the reader marks required only the properties it reads.
945 properties->erase(name);
946 continue;
947 }
948 auto* const object = property.get_if<Json::object_t>();
949 if (object == nullptr) {
950 continue;
951 }
952 auto const assign = [&](std::string const& key, Json value) { object->insert_or_assign(key, value); };
953 if (overlay.hidden) {
954 Json flag{};
955 flag.data = true;
956 assign("x-hidden", flag);
957 }
958 if (overlay.label.has_value()) {
959 Json text{};
960 text.data = *overlay.label;
961 assign("title", text);
962 }
963 if (overlay.widget.has_value()) {
964 Json text{};
965 text.data = *overlay.widget;
966 assign("x-widget", text);
967 }
968 if (overlay.blankAs.has_value()) {
969 Json text{};
970 text.data = std::string{*overlay.blankAs == BlankAs::Empty ? "empty" : "omit"};
971 assign("x-blankAs", text);
972 }
973 }
974 return {};
975 }
976
977 /// Applies an overlay's `unit` and `decimals` to the field read from the schema.
978 [[nodiscard]] static std::expected<void, SchemaError> applyUnitOverlay(FieldSpec& spec,
979 FieldOverlay const& overlay) {
980 if (overlay.unit.has_value()) {
981 if (spec.units.empty()) {
982 spec.unit = *overlay.unit;
983 } else {
984 auto const found = std::ranges::find(spec.units, *overlay.unit, &UnitOption::id);
985 if (found == spec.units.end()) {
986 return std::unexpected(
987 SchemaError{.path = "fields." + spec.name + ".unit",
988 .message = "\"" + *overlay.unit + "\" is not one of the field's units"});
989 }
990 spec.entryUnit = static_cast<std::size_t>(found - spec.units.begin());
991 }
992 }
993 if (overlay.decimals.has_value()) {
994 std::uint32_t const places = std::min(*overlay.decimals, math::kMaxDecimalPlaces);
995 if (spec.units.empty()) {
996 spec.displayDecimals = places;
997 } else {
998 spec.units.at(spec.entryUnit).decimals = places;
999 }
1000 }
1001 return {};
1002 }
1003
1004 [[nodiscard]] static std::expected<std::vector<FieldGroupSpec>, SchemaError> readGroups(
1005 detail::engine::Json const& dom, std::vector<FieldSpec> const& fields) {
1006 using detail::engine::Json;
1007 std::vector<FieldGroupSpec> groups;
1008 Json const* const layout = detail::engine::member(dom, "x-layout");
1009 Json const* const declared = layout == nullptr ? nullptr : detail::engine::member(*layout, "groups");
1010 if (declared != nullptr && !declared->is_array()) {
1011 return std::unexpected(SchemaError{.path = "x-layout.groups", .message = "\"groups\" must be an array"});
1012 }
1013 if (declared != nullptr) {
1014 for (Json const& node : declared->get_array()) {
1015 FieldGroupSpec group{};
1016 if (auto const* const title = detail::engine::stringAt(node, "title")) {
1017 group.title = *title;
1018 }
1019 group.kind = groupKindOf(detail::engine::stringAt(node, "kind"));
1020 groups.push_back(std::move(group));
1021 }
1022 }
1023 FieldGroupSpec trailing{};
1024 trailing.implicit = true;
1025 for (FieldSpec const& field : fields) {
1026 if (field.group.has_value() && *field.group < groups.size()) {
1027 groups.at(*field.group).fields.push_back(field.name);
1028 } else {
1029 trailing.fields.push_back(field.name);
1030 }
1031 }
1032 if (!trailing.fields.empty()) {
1033 groups.push_back(std::move(trailing));
1034 }
1035 return groups;
1036 }
1037
1038 std::string _actionType;
1039 std::string _title;
1040 std::vector<FieldSpec> _fields;
1041 std::vector<FieldGroupSpec> _groups;
1042 std::vector<RuleExpr> _rules;
1043 SubmitMode _submitMode = SubmitMode::Automatic;
1044};
1045
1046} // namespace morph::forms
Node text(TextOptions const &options)
A text node.
Definition author.hpp:3126
AnyHandle list(std::vector< AnyHandle > const &items)
{"list": [...]}: a list built from expressions.
Definition author.hpp:1123
Handle< T > ref(std::string const &path)
A named read, {"ref": "name.path"}: a param, state, let, query value, element value or bound name.
Definition author.hpp:1137
Handle< std::string > format(AnyHandle const &pattern, std::vector< AnyHandle > args)
format: a template with {0}, {1}, … placeholders.
Definition author.hpp:970
AnyHandle prop(std::string const &name)
A prop of a component, inside the component's fallback.
Definition author.hpp:1228
morph::forms::BlankAs: what a blank string field submits.
BlankAs
What a renderer submits for a string field the user left blank (FieldMeta::blankAs).
Definition blank_as.hpp:17
@ Omit
Blank is "no value": the member is left out of the payload (the default).
Definition blank_as.hpp:19
The immutable runtime model of one action's form.
std::span< FieldGroupSpec const > groups() const noexcept
The layout groups: the declared x-layout groups, then an implicit group of the fields none names; wit...
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.
std::span< FieldSpec const > fields() const noexcept
The top-level fields, in x-order.
SubmitMode submitMode() const noexcept
When the form submits.
FormModel withSubmitMode(SubmitMode mode) const
The same model with another submit mode: a document's submit choosing over x-submitMode.
std::string_view actionType() const noexcept
The action type the form submits.
std::string_view title() const noexcept
The schema's top-level title, or the action type.
std::span< RuleExpr const > rules() const noexcept
The parsed x-rules, in declaration order.
FieldSpec const * find(std::string_view name) const noexcept
A top-level field by wire name.
static FormModel forAction()
The model of a registered action's own form: fromSchema over ActionTraits<A>::typeId() and schemaJson...
static std::expected< RuleExpr, SchemaError > parse(detail::engine::Json const &node, std::string const &path)
Reads one rule or condition node and translates it.
Definition rules.hpp:219
Resolved const & resolved()
The process registries, matched and validated on the first call.
Definition effects.hpp:511
FieldKind
The control a field gets, which the JSON type alone does not say.
@ EpochDateTime
An instant as int64 seconds since the epoch (UTC): x-widget: epochSeconds on an integer.
@ Slider
A number on a track (x-widget: slider with x-min/x-max).
@ Array
A list of scalars, entered comma-separated.
@ Date
A calendar date, YYYY-MM-DD.
@ Choice
A set fetched by executing an options action.
@ Object
A nested object.
@ Quantity
An exact {num, den, dp} in a unit, with optional convertible alternatives.
@ EpochDate
A calendar date as int64 days since the epoch: x-widget: epochDays on an integer.
@ DateTime
An instant, entered in the display zone, sent as ISO-8601 UTC.
@ Decimal
An exact {num, den, dp} with a declared precision and no unit.
@ Multiline
A string edited over several lines (x-widget: textarea).
@ Integer
An integer of any width; every digit is kept.
@ Number
A plain JSON number (a double/float member).
@ Enum
A closed set the schema states.
@ ObjectArray
A list of nested objects.
constexpr std::size_t kMaxNestingDepth
How many object levels below the action a schema is described; a deeper member is FieldSpec::truncate...
SubmitMode
When a form submits.
@ Automatic
Whenever it becomes ready with a changed body (no x-submitMode).
@ Explicit
Only on FormSession::submit() (x-submitMode: explicit).
std::map< std::string, FieldOverlay, std::less<> > FieldOverlays
The overlays of a form's fields, by wire name.
constexpr std::string_view fieldKindName(FieldKind kind) noexcept
The stable name of a field kind (what Overrides::byKind documents and tests print).
const std::string & schemaJson()
Generates the JSON Schema for action type A, ready for a client-side form renderer.
Definition forms.hpp:3705
Visual structure (sections, tabs, accordions, column spans) layered over an action's flat field list.
GroupKind
The container kind a FieldGroup renders as.
Definition layout.hpp:32
@ Tab
A pane of a shared tab bar; consecutive Tab groups share one bar.
Definition layout.hpp:34
@ Section
A titled fieldset, stacked vertically (the default).
Definition layout.hpp:33
Exact rational arithmetic for morph model/action values.
x-rules read into RuleExpr trees, translated into the UI document's expression language (expr/1) and ...
One selectable value of an Enum or a Choice.
std::string label
What the user sees.
std::string valueJson
The value as the JSON literal the body carries ("\"Viewer\\"\"</tt>, <tt>\"9007199254740993\").
bool operator==(ChoiceOption const &other) const =default
Member-wise equality.
Where a Choice's options come from (x-optionsAction and its companions).
std::string valueField
The row member submitted as the value (x-optionValue).
bool operator==(ChoiceSpec const &other) const =default
Member-wise equality.
std::string optionsAction
The action whose result rows are the options.
std::vector< std::string > dependsOn
Sibling wire names whose values parameterise the options request (x-optionsDependsOn).
std::string labelField
The row member shown (x-optionLabel).
An optionally-empty value selected from options served by another action.
Definition choice.hpp:86
One x-layout group, or the implicit group of the fields no group names.
std::vector< std::string > fields
The wire names of the fields in the group, in x-order.
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).
GroupKind kind
Section, tab or accordion; the implicit group is a Section with implicit set.
What a document (or an application) changes about one top-level field before the session is built: a ...
std::optional< BlankAs > blankAs
Replaces x-blankAs.
bool omit
Removes the member from the form and from its body.
bool hidden
Keeps the member in the body and out of the view (x-hidden).
std::optional< std::string > label
Replaces the label (title).
std::optional< std::uint32_t > decimals
The places a Decimal or Quantity is entered with in its entry unit, or a Number's display decimals....
std::optional< std::string > unit
The unit a Quantity is entered in: the id of one of its units (its canonical unit or an alternative)....
std::optional< std::string > widget
Replaces x-widget, so the kind is read again (textarea, radio, slider, epochDays, …).
Everything the engine knows about one field, from its schema property.
std::optional< std::string > exactMaximum
x-exactMaximum: an integer bound's exact digits.
std::string unitAscii
ExtUnits.unitAscii.
std::string path
The path from the action: name, parent.name, or rows[].name for a collection's member.
int colspan
x-colspan: grid columns the field spans (at least 1).
std::size_t entryUnit
The index into units a draft starts in (and returns to on prefill and reset): 0, the canonical unit,...
std::string widget
x-widget, empty when none.
std::optional< math::Rational > instanceMaximum
x-maximum: a per-instance upper bound.
std::optional< std::uint32_t > displayDecimals
x-displayDecimals: a plain Number's entry and display precision.
std::optional< math::Rational > instanceMinimum
x-minimum: a per-instance lower bound (InstanceConstraints::decorate).
std::string placeholder
x-placeholder.
std::vector< ChoiceOption > enumOptions
An Enum's options, in schema order.
std::string title
The schema's title, or the wire name.
std::string itemType
An Array's item JSON type ("string" when undeclared).
BlankAs blankAs
x-blankAs (Text and Multiline only): whether a cleared field submits "".
std::int64_t sliderMin
A Slider's track minimum (x-min).
std::optional< std::uint32_t > decimalPlaces
x-decimalPlaces: the declared precision of a Decimal or Quantity.
std::string help
The schema's description.
std::optional< ChoiceSpec > choice
A Choice's options source.
std::string jsonType
The first declared JSON type ("integer", …), empty when the property declares none.
std::int64_t sliderMax
A Slider's track maximum (x-max).
std::optional< std::string > multipleOf
A positive multipleOf, as canonical decimal text.
bool readOnly
x-readonly.
bool truncated
An Object or ObjectArray whose members are not described: its $ref is already on the path,...
std::optional< std::string > minimum
minimum, as canonical decimal text.
FieldKind kind
The control and encoder.
std::optional< std::string > maximum
maximum, as canonical decimal text.
bool bytes
A string member whose schema says contentEncoding: base64: its wire value is a file's bytes,...
std::string name
The wire name.
bool required
Named in the containing object's required.
std::vector< FieldSpec > children
An Object's members, or an ObjectArray's element members, in x-order.
std::vector< UnitOption > units
A Decimal's or Quantity's entry units: the canonical one first, then x-unitAlternatives.
std::int64_t sliderStep
A Slider's increment (x-step, at least 1).
std::vector< std::string > computedInputs
x-computed.inputs: the members a computed field derives from.
std::optional< std::size_t > group
x-section: the index of the x-layout group the field sits in.
std::string unit
The display unit (ExtUnits.unitUnicode, else unitAscii), empty when none.
std::optional< std::string > exactMinimum
x-exactMinimum: an integer bound's exact digits.
std::string i18nKey
x-i18nKey: the explicit message-key stem, empty when none is declared.
A string field edited as a multi-line text area.
Why a schema does not read as a form: where, and why.
Definition rules.hpp:34
One unit a Quantity can be entered in; the first of a field's units is canonical.
std::string display
What a selector shows.
std::int64_t num
Numerator of the exact unit-to-canonical ratio.
std::int64_t den
Denominator of the exact unit-to-canonical ratio.
bool operator==(UnitOption const &other) const =default
Member-wise equality.
std::uint32_t decimals
Fraction digits accepted in this unit.
std::string id
The unit id (ExtUnits.unitAscii, or the alternative's id).
Exact text helpers the forms engine shares: JSON quoting and reading, canonical decimal comparison an...