33namespace morph::forms {
86 return "epochDateTime";
253 std::optional<std::string>
unit;
278namespace detail::engine {
284[[nodiscard]]
inline bool contains(
auto const& range,
auto const& value) {
285 return std::ranges::find(range, value) != std::ranges::end(range);
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())};
300 return *std::next(items.begin(),
static_cast<std::ptrdiff_t
>(index));
308 explicit SchemaReader(Json
const& root) : _defs{member(root,
"$defs")} {}
313 [[nodiscard]] Json resolveRef(Json
const& prop)
const {
314 if (!
prop.is_object()) {
315 return emptyObject();
317 auto const*
const ref = stringAt(prop,
"$ref");
318 if (ref ==
nullptr) {
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()) {
328 for (
auto const& [key, value] :
prop.get_object()) {
329 merged.get_object().insert_or_assign(key, value);
338 [[nodiscard]] Json resolveProp(Json
const& prop)
const {
340 Json
const* branches = member(resolved,
"anyOf");
341 if (branches ==
nullptr || !branches->is_array()) {
342 branches = member(resolved,
"oneOf");
344 if (branches ==
nullptr || !branches->is_array()) {
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") {
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);
359 for (
auto const& [key, value] :
resolved.get_object()) {
361 merged.get_object().insert_or_assign(key, value);
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) {
384 if (!props->is_object()) {
385 return std::unexpected(
386 SchemaError{.path = pathPrefix +
"properties", .message =
"\"properties\" must be an object"});
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);
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));
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."};
408 return std::unexpected(
409 SchemaError{.path = std::move(path), .message =
"a property schema must be a JSON object"});
411 auto field = describeOne(raw, name, depth, pathPrefix, refChain, contains(required, name));
413 return std::unexpected(field.error());
415 fields.push_back(*std::move(field));
423 [[nodiscard]]
static std::optional<std::uint64_t> unsignedOf(Json
const* value) {
424 if (value ==
nullptr) {
427 if (
auto const*
const asUnsigned = value->get_if<std::uint64_t>()) {
430 if (
auto const*
const asSigned = value->get_if<std::int64_t>(); asSigned !=
nullptr && *asSigned >= 0) {
431 return static_cast<std::uint64_t
>(*asSigned);
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);
443 [[nodiscard]]
static std::optional<std::int64_t> signedOf(Json
const* value) {
444 if (value ==
nullptr) {
447 if (
auto const*
const asSigned = value->get_if<std::int64_t>()) {
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);
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);
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) {
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);
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);
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;
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;
498 [[nodiscard]]
static std::string refNameOf(Json
const& raw) {
499 if (
auto const*
const ref = stringAt(raw,
"$ref")) {
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")) {
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");
520 if (branches ==
nullptr || !branches->is_array()) {
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") {
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)});
535 auto inner = nested ? constBranches(branch,
false) : std::nullopt;
536 if (!inner || inner->empty()) {
539 rows.insert(rows.end(), inner->begin(), inner->end());
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)});
555 return constBranches(prop,
true).value_or(std::vector<ChoiceOption>{});
558 [[nodiscard]]
static std::optional<math::Rational> rationalOf(Json
const* node) {
559 if (node ==
nullptr) {
562 auto const num = signedOf(member(*node,
"num"));
563 auto const den = signedOf(member(*node,
"den"));
564 if (!num || !den || *den == 0) {
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))}};
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); };
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()) {
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);
606 if (Json
const*
const minimum = member(prop,
"minimum")) {
607 spec.minimum = numberText(*minimum);
609 if (Json
const*
const maximum = member(prop,
"maximum")) {
610 spec.maximum = numberText(*maximum);
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);
618 if (
auto const*
const exact = stringAt(prop,
"x-exactMinimum");
619 exact !=
nullptr && isCanonicalInteger(*exact)) {
620 spec.exactMinimum = *exact;
622 if (
auto const*
const exact = stringAt(prop,
"x-exactMaximum");
623 exact !=
nullptr && isCanonicalInteger(*exact)) {
624 spec.exactMaximum = *exact;
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));
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;
637 if (
auto const section = unsignedOf(either(raw, prop,
"x-section"))) {
638 spec.group =
static_cast<std::size_t
>(*section);
641 static_cast<int>(std::clamp<std::int64_t>(signedOf(either(raw, prop,
"x-colspan")).value_or(1), 1, 1024));
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>();
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);
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);
672 auto children = describe(items, depth + 1, spec.path +
"[].", itemChain);
674 return std::unexpected(children.error());
676 spec.children = *std::move(children);
678 spec.truncated =
true;
681 spec.kind = FieldKind::Array;
682 spec.itemType = itemTypes.empty() ? std::string{
"string"} : itemTypes.front();
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;
690 choice.optionsAction = *optionsAction;
691 if (
auto value = textOf(raw, prop,
"x-optionValue"); !value.empty()) {
692 choice.valueField = std::move(value);
694 if (
auto label = textOf(raw, prop,
"x-optionLabel"); !label.empty()) {
695 choice.labelField = std::move(label);
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);
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()) {
718 option.id = textOf(alternative, alternative,
"id");
719 option.display = textOf(alternative, alternative,
"display");
720 if (option.display.empty()) {
721 option.display = option.id;
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));
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));
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);
756 return std::unexpected(children.error());
758 spec.children = *std::move(children);
760 spec.truncated =
true;
762 }
else if (spec.widget ==
"textarea") {
763 spec.kind = FieldKind::Multiline;
765 spec.kind = FieldKind::Text;
767 if ((spec.kind == FieldKind::Text || spec.kind == FieldKind::Multiline) &&
768 textOf(raw, prop,
"x-blankAs") ==
"empty") {
769 spec.blankAs = BlankAs::Empty;
771 spec.bytes = spec.jsonType ==
"string" && textOf(raw, prop,
"contentEncoding") ==
"base64";
801 using detail::engine::Json;
802 auto parsedDom = detail::engine::parseJson(
schemaJson);
804 return std::unexpected(
SchemaError{.path =
"", .message =
"the schema is not valid JSON"});
806 if (!parsedDom->is_object()) {
807 return std::unexpected(SchemaError{.path =
"", .message =
"the schema is not a JSON object"});
809 if (
auto applied = applyOverlays(*parsedDom, overlays); !applied) {
810 return std::unexpected(applied.error());
812 Json
const*
const dom = &*parsedDom;
815 auto const*
const title = detail::engine::stringAt(*dom,
"title");
817 detail::engine::SchemaReader
const reader{*dom};
818 auto fields = reader.describe(*dom, 0,
"", {});
820 return std::unexpected(
fields.error());
822 model._fields = *std::move(
fields);
823 if (
auto groups = readGroups(*dom, model._fields);
groups) {
824 model._groups = *std::move(
groups);
826 return std::unexpected(
groups.error());
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"});
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++) +
"]");
836 return std::unexpected(parsed.error());
838 model._rules.push_back(*std::move(parsed));
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) {
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());
859 FormModel copy = *
this;
860 copy._submitMode = mode;
881 [[nodiscard]] std::string_view
actionType() const noexcept {
return _actionType; }
885 [[nodiscard]] std::string_view
title() const noexcept {
return _title; }
889 [[nodiscard]] std::span<FieldSpec const>
fields() const noexcept {
return _fields; }
894 [[nodiscard]] std::span<FieldGroupSpec const>
groups() const noexcept {
return _groups; }
898 [[nodiscard]] std::span<RuleExpr const>
rules() const noexcept {
return _rules; }
909 return found == _fields.end() ? nullptr : &*found;
915 [[nodiscard]]
static GroupKind groupKindOf(std::string
const* kind) {
916 if (kind !=
nullptr && *kind ==
"tab") {
919 if (kind !=
nullptr && *kind ==
"accordion") {
920 return GroupKind::Accordion;
922 return GroupKind::Section;
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()) {
934 auto*
const properties = dom.get_object().contains(
"properties")
935 ? dom.get_object().find(
"properties")->second.get_if<Json::object_t>()
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"});
942 Json&
property = properties->find(name)->second;
945 properties->erase(name);
948 auto*
const object =
property.get_if<Json::object_t>();
949 if (
object ==
nullptr) {
952 auto const assign = [&](std::string
const& key, Json value) {
object->insert_or_assign(key, value); };
953 if (overlay.hidden) {
956 assign(
"x-hidden", flag);
958 if (overlay.label.has_value()) {
960 text.data = *overlay.label;
961 assign(
"title", text);
963 if (overlay.widget.has_value()) {
965 text.data = *overlay.widget;
966 assign(
"x-widget", text);
968 if (overlay.blankAs.has_value()) {
970 text.data = std::string{*overlay.blankAs == BlankAs::Empty ?
"empty" :
"omit"};
971 assign(
"x-blankAs", text);
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;
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"});
990 spec.entryUnit =
static_cast<std::size_t
>(found - spec.units.begin());
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;
998 spec.units.at(spec.entryUnit).decimals = places;
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"});
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;
1019 group.kind = groupKindOf(detail::engine::stringAt(node,
"kind"));
1020 groups.push_back(std::move(group));
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);
1029 trailing.fields.push_back(field.name);
1032 if (!trailing.fields.empty()) {
1033 groups.push_back(std::move(trailing));
1038 std::string _actionType;
1040 std::vector<FieldSpec> _fields;
1041 std::vector<FieldGroupSpec> _groups;
1042 std::vector<RuleExpr> _rules;
1043 SubmitMode _submitMode = SubmitMode::Automatic;
Node text(TextOptions const &options)
A text node.
AnyHandle list(std::vector< AnyHandle > const &items)
{"list": [...]}: a list built from expressions.
Handle< T > ref(std::string const &path)
A named read, {"ref": "name.path"}: a param, state, let, query value, element value or bound name.
Handle< std::string > format(AnyHandle const &pattern, std::vector< AnyHandle > args)
format: a template with {0}, {1}, … placeholders.
AnyHandle prop(std::string const &name)
A prop of a component, inside the component's fallback.
morph::forms::BlankAs: what a blank string field submits.
BlankAs
What a renderer submits for a string field the user left blank (FieldMeta::blankAs).
@ Omit
Blank is "no value": the member is left out of the payload (the default).
Resolved const & resolved()
The process registries, matched and validated on the first call.
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.
@ 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).
Visual structure (sections, tabs, accordions, column spans) layered over an action's flat field list.
GroupKind
The container kind a FieldGroup renders as.
@ Tab
A pane of a shared tab bar; consecutive Tab groups share one bar.
@ Section
A titled fieldset, stacked vertically (the default).
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 ...
Exact text helpers the forms engine shares: JSON quoting and reading, canonical decimal comparison an...