Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,22 @@ API surface).
`resolve()` takes it as an optional sixth argument, consulted after unit and
before type. See `docs/spec/forms/forms.md`, "Theming / component-override
registry" (fixes #812).

- **`FieldMeta::unit` / `FieldMeta::decimals` — a display unit and precision
for a plain `double`/`float`/integral member.** A DTO holding lab readings as
plain `double`s had no way to tell a renderer "kg/m³, three decimals": that
knowledge lived only in `Quantity`'s type. `unit` is emitted as the
property's `ExtUnits` (the key a `Quantity` already carries, so the shipped
renderer's unit suffix, `SlotRegistry.byUnit` and view columns all see it
unchanged); `decimals` is emitted as the new `x-displayDecimals`, **not**
`x-decimalPlaces`, because the latter switches a property to the exact
`{num,den,dp}` encoding a `double` cannot decode. `DynamicForm` keeps the
JSON-number encoding, refuses an entry with more fraction digits than
declared (as it does for a `Quantity`), spells the placeholder from it, and
hands slots `field.decimals` / `field.decimalsDeclared`. Both keys are
ignored on a `Quantity` member and apply at any nesting depth. See
`docs/spec/forms/forms.md`, "Display unit and decimals for a plain member".

- **The MorphForms QML module installs, as the `forms_qml` component.**
`cmake --install` of a `MORPH_BUILD_FORMS_QML=ON` build installed the
controller-core header and nothing of the QML module, so a packaged morph
Expand Down
59 changes: 57 additions & 2 deletions docs/spec/forms/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,8 @@ struct FieldMeta {
std::optional<math::Rational> minimum{}; // disengaged = no floor
std::optional<math::Rational> maximum{}; // disengaged = no ceiling
std::optional<math::Rational> multipleOf{}; // disengaged = any value
std::string_view unit{}; // "" = no display unit (plain members only)
std::optional<math::DecimalPlaces> decimals{}; // disengaged = no display precision
};

struct RecordMeasurement {
Expand Down Expand Up @@ -567,6 +569,47 @@ A per-*instance* `x-minimum`/`x-maximum` written by
composes with a compiled bound rather than replacing it: the renderer checks
both, so an instance range narrows the declared one and never widens it.

### Display unit and decimals for a plain member — `unit` / `decimals`

A `Quantity` carries its unit and its precision in its type. A DTO whose
numeric members are plain `double`s has neither, so `FieldMeta` declares them:

```cpp
struct RecordDensity {
double density = 0.0;
double temperature = 0.0;

static constexpr std::array fieldMetadata{
FieldMeta{.field = "density", .unit = "kg/m³", .decimals = math::DecimalPlaces{3}},
FieldMeta{.field = "temperature"}.withUnit("°C"),
};
};
```

- **`unit` is emitted as `ExtUnits`**, the key a `Quantity`'s unit already
travels in, with the one string in both `unitAscii` and `unitUnicode`. Every
reader of a unit therefore finds a plain member's where it finds a
`Quantity`'s: the shipped renderer's unit suffix, `SlotRegistry.byUnit`, and
a view's `v-columns` entry (`views.hpp` copies `ExtUnits` off the property
node). It is presentation only: nothing converts through it, and it never
reaches the payload.
- **`decimals` is emitted as `x-displayDecimals`, deliberately not
`x-decimalPlaces`.** `x-decimalPlaces` hands a property the exact
`{num,den,dp}` encoding (see [Plain number fields](#plain-number-fields--type-number):
a declared precision wins over the `"number"` type), and a `double` member
cannot decode that object. `x-displayDecimals` keeps the JSON-number encoding
and only tells the renderer how many fraction digits to show and accept. It
is read only for a `"number"` property with no `x-decimalPlaces`.
- **Both are ignored on a `Quantity` member**, whose unit and declared
precision are part of its type and already emitted; a second declaration
could only disagree with the first. A `decimals` above `kMaxDecimalPlaces`
is ignored too, as a non-positive `multipleOf` is.
- **Both apply at any depth**, like the rest of `FieldMeta`: a `std::vector<Row>`
element's own `fieldMetadata` stamps the row type's properties.

Neither is checked server-side. Like `placeholder`, they are presentation;
the one gate that follows from `decimals` is the renderer's entry limit below.

### Field metadata is not a security control

`x-readonly` and `x-hidden` are presentation only. The field still travels in
Expand All @@ -589,8 +632,10 @@ member of the action at all.
| `minimum` | property node (sibling of `$ref`) | number | Inclusive lower bound on the field's value, from `FieldMeta::minimum`. Omitted when not declared. Standard JSON-Schema vocabulary, not an `x-*` key — see [Per-field scalar bounds](#per-field-scalar-bounds--minimum--maximum--multipleof). |
| `maximum` | property node (sibling of `$ref`) | number | Inclusive upper bound, from `FieldMeta::maximum`. Omitted when not declared. |
| `multipleOf` | property node (sibling of `$ref`) | number | The field's value must be an exact integer multiple of this, from `FieldMeta::multipleOf`. `1` is how "whole number" is spelled. Omitted when not declared, or when the declared value is not strictly positive. |
| `ExtUnits` | property node (sibling of `$ref`) | object | A plain member's display unit, from `FieldMeta::unit`, as `{"unitAscii": unit, "unitUnicode": unit}` — the shape a `Quantity` carries. Omitted when empty, and never emitted for a `Quantity` member. See [Display unit and decimals](#display-unit-and-decimals-for-a-plain-member--unit--decimals). |
| `x-displayDecimals` | property node (sibling of `$ref`) | non-negative integer | A plain number's display and entry precision, from `FieldMeta::decimals`. Omitted when disengaged, above `kMaxDecimalPlaces`, or on a `Quantity` member. |

All ten keys are additive and non-breaking, extending the renderer-contract
All twelve keys are additive and non-breaking, extending the renderer-contract
table below without renaming or retyping any existing key, per this program's
versioning stance (see "Design principle" above). A
renderer that ignores them falls back to today's behavior exactly: it shows
Expand Down Expand Up @@ -788,7 +833,8 @@ below) `DynamicForm.qml`'s `resolveProp` does exactly this dual read.
| `x-maximum` | property node (sibling of `$ref`) | object `{num,den,dp}` | Inclusive upper bound, same source and shape as `x-minimum`. |
| `x-instanceConstraints` | top-level (object) | array of strings | Wire field names whose keys were written from *instance* data rather than derived from the compiled action type. Present only on a decorated schema. A renderer needing to know whether an `x-decimalPlaces`/`x-minimum`/`x-maximum` is instance-sourced checks membership here rather than guessing. |
| `format` | `Timestamp` property (or its `$def`) | string, value `"date-time"` | Standard JSON-Schema vocabulary (stamped by glaze, not by morph). The renderer shows a date-time input; the wire value is the ISO-8601 string `Timestamp` serialises to. No `x-*` extension is used for timestamps. |
| `ExtUnits` | `$def` of the `Quantity`'s unit type (reached via the property's `$ref`) | object | Glaze-stamped block describing the field's **canonical** unit. Two fields: `unitAscii` (the stable ascii id, e.g. `"kg_per_m3"` — sourced from `UnitMeta::id`) and `unitUnicode` (the human display text, e.g. `"kg/m³"` — from `UnitMeta::display`). This is the unit a payload value is always denominated in, and the reference point the `num`/`den` of every `x-unitAlternatives` entry converts *to*. A renderer resolves the property's `$ref` into `$defs` to read `ExtUnits.unitAscii`/`unitUnicode` (it is **not** on the property node next to the `x-*` keys) to label the field and anchor the unit selector. |
| `x-displayDecimals` | property node (sibling of `$ref`) | non-negative integer | A plain `"number"` member's display and entry precision, from `FieldMeta::decimals`. Read only when the property has no `x-decimalPlaces`; the value keeps its JSON-number encoding, and an entry with more fraction digits is refused. See [Display unit and decimals](#display-unit-and-decimals-for-a-plain-member--unit--decimals). |
| `ExtUnits` | `$def` of the `Quantity`'s unit type (reached via the property's `$ref`) — or, for a plain member declaring `FieldMeta::unit`, the property node | object | Glaze-stamped block describing the field's **canonical** unit. Two fields: `unitAscii` (the stable ascii id, e.g. `"kg_per_m3"` — sourced from `UnitMeta::id`) and `unitUnicode` (the human display text, e.g. `"kg/m³"` — from `UnitMeta::display`). This is the unit a payload value is always denominated in, and the reference point the `num`/`den` of every `x-unitAlternatives` entry converts *to*. A renderer resolves the property's `$ref` into `$defs` to read `ExtUnits.unitAscii`/`unitUnicode` (it is **not** on the property node next to the `x-*` keys) to label the field and anchor the unit selector. On a plain member it is the `FieldMeta::unit` display text in both subfields, and there is no unit selector. |
| `x-layout` | top-level (object) | object | The form's group structure: `{ "groups": [ { "title": string, "kind": "section"\|"tab"\|"accordion", "fields": [wire-key,…] }, … ] }`, in `A::formLayout` declaration order. Emitted only when the action declares `formLayout`. The renderer builds the named containers in array order and places each field in its group; fields absent from every group go in a trailing default group. |
| `x-group` | property node (sibling of `$ref`) | string | The title of the group this field belongs to. Omitted for a field in the implicit default group, or when `x-layout` is absent. |
| `x-section` | property node (sibling of `$ref`) | non-negative integer | The 0-based index of this field's group in `x-layout.groups`. Omitted under the same conditions as `x-group`. |
Expand Down Expand Up @@ -973,6 +1019,15 @@ number:
| an integral member | `"type": "integer"` (+ `x-exactMinimum`/`x-exactMaximum` past 2^53) | bare integer, gated on the [exact string bounds](#exact-numeric-bounds--x-exactminimum--x-exactmaximum) |
| `double` / `float` | `"type": "number"` | bare JSON number, as above |

**`x-displayDecimals` is an entry limit.** When a plain number declares one
(`FieldMeta::decimals`, see [Display unit and decimals](#display-unit-and-decimals-for-a-plain-member--unit--decimals)),
an entry with more fraction digits than that has no literal, exactly as an
over-precise `Quantity` entry has none: it is refused, never rounded, because a
rounded value is one the user did not type. The encoding stays a bare JSON
number. The field descriptor carries the count as `decimals` (with
`decimalsDeclared` telling a slot whether `0` was declared or merely
defaulted), and the placeholder spells it (`"0.000"`).

A generated `Quantity` property is therefore not `"number"` at all. The order
still matters, because a *decorated* schema can put `x-decimalPlaces` on a
property whose type is `"number"` — a precise field spelled the plain way. The
Expand Down
84 changes: 82 additions & 2 deletions include/morph/forms/forms.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@
/// declaration in C++, so an action's `validate()` enforces exactly what
/// the client was served. Bounds are per *field*, unlike
/// `UnitTraits::bounds`, which is per unit.
/// - **`ExtUnits` / `x-displayDecimals`** — for a *non-`Quantity`* field whose
/// `FieldMeta` declares `unit` / `decimals`: the display unit (in the same
/// `ExtUnits` shape a `Quantity` carries) and the fraction digits a renderer
/// shows and accepts. Presentation only — the member keeps its plain JSON
/// encoding.
/// - **`x-computed` / `x-readonly`** — for a member listed as the destination
/// of an action's `computedFields` declaration: the field is derived from
/// sibling inputs (named in `x-computed.inputs`) and must not be rendered as
Expand Down Expand Up @@ -188,7 +193,8 @@
namespace morph::forms {

/// @brief Per-field presentation overrides and scalar bounds: label, help,
/// placeholder, read-only, hidden, `minimum`/`maximum`/`multipleOf`
/// placeholder, read-only, hidden, `minimum`/`maximum`/`multipleOf`,
/// and a plain member's display `unit`/`decimals`
/// (docs/spec/forms/forms.md, "Field metadata").
///
/// An action opts in with a `static constexpr std::array<FieldMeta, N>`
Expand All @@ -198,7 +204,8 @@ namespace morph::forms {
/// than `field` defaults to "not declared": an empty `label`/`help`/
/// `placeholder` means "infer the title, omit the rest"; `readOnly`/`hidden`
/// default to `false`; a disengaged `minimum`/`maximum`/`multipleOf` emits
/// nothing and checks nothing. `mergeSchemaExtras` looks up the entry (if any)
/// nothing and checks nothing; an empty `unit` and a disengaged `decimals`
/// emit nothing. `mergeSchemaExtras` looks up the entry (if any)
/// matching each reflected member by wire key and patches the property node;
/// an entry naming a field that does not exist on the action is ignored.
///
Expand Down Expand Up @@ -275,6 +282,31 @@ struct FieldMeta {
/// exactly the same set of values as its magnitude.
std::optional<::morph::math::Rational> multipleOf{};

/// @brief Display unit for a member whose C++ type carries none (a plain
/// `double`, `float` or integral member); emitted as the property's
/// `ExtUnits` (`{"unitAscii": unit, "unitUnicode": unit}`), the key
/// a `Quantity` already carries. Empty emits nothing.
///
/// Presentation only: the unit never travels in the payload and nothing
/// converts through it. **Ignored on a `Quantity` member**, whose unit is
/// part of its type and already emitted.
// Spelled like every sibling's default, which a consumer's
// -Wmissing-field-initializers reads as "has a default".
// NOLINTNEXTLINE(readability-redundant-member-init)
std::string_view unit{};

/// @brief Display and entry precision for a plain `double`/`float` member;
/// emitted as `x-displayDecimals`. Disengaged emits nothing.
///
/// Deliberately not `x-decimalPlaces`: that key hands a property the exact
/// `{num,den,dp}` encoding, which a `double` cannot decode. This one keeps
/// the plain JSON-number encoding and only tells a renderer how many
/// fraction digits to show and accept. **Ignored on a `Quantity` member**
/// (its `x-decimalPlaces` is authoritative) and when it exceeds
/// `morph::math::kMaxDecimalPlaces`.
// NOLINTNEXTLINE(readability-redundant-member-init) -- as `unit` above
std::optional<::morph::math::DecimalPlaces> decimals{};

/// @brief Returns a copy with `placeholder` set to @p text.
/// @param text The placeholder hint.
/// @return The updated descriptor.
Expand Down Expand Up @@ -327,6 +359,24 @@ struct FieldMeta {
copy.multipleOf = step;
return copy;
}

/// @brief Returns a copy with `unit` set to @p text.
/// @param text The display unit, e.g. `"kg/m³"`.
/// @return The updated descriptor.
[[nodiscard]] constexpr FieldMeta withUnit(std::string_view text) const noexcept {
FieldMeta copy = *this;
copy.unit = text;
return copy;
}

/// @brief Returns a copy with `decimals` set to @p places.
/// @param places Fraction digits to display and accept.
/// @return The updated descriptor.
[[nodiscard]] constexpr FieldMeta withDecimals(::morph::math::DecimalPlaces places) const noexcept {
FieldMeta copy = *this;
copy.decimals = places;
return copy;
}
};

/// @brief Concept: a field type with an internal empty state (`Quantity`,
Expand Down Expand Up @@ -2174,6 +2224,35 @@ inline void annotateDeclaredBounds(glz::generic_u64& property, const FieldMeta&
}
}

/// @brief Stamps @p meta's display `unit` (as `ExtUnits`) and `decimals` (as
/// `x-displayDecimals`) onto @p property, unless @p Member is a
/// `Quantity`.
///
/// `ExtUnits` is the key a `Quantity` already carries, so every reader of a
/// unit -- a renderer's suffix label, `SlotRegistry.byUnit`, a view column --
/// finds a plain member's unit where it finds a `Quantity`'s. A `Quantity`'s
/// own unit and precision are part of its type; a `FieldMeta` restating them
/// could only disagree, so for one this is a no-op.
/// @tparam Member The static type of the member being annotated.
/// @param property Property node to annotate in place.
/// @param meta The field's declared metadata.
// NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) -- glaze DOM requires operator[]
template <typename Member>
void annotateDisplayUnit(glz::generic_u64& property, const FieldMeta& meta) {
if constexpr (!units::isQuantity<Member>) {
if (!meta.unit.empty()) {
glz::generic_u64 units{};
units["unitAscii"] = std::string{meta.unit};
units["unitUnicode"] = std::string{meta.unit};
property["ExtUnits"] = std::move(units);
}
if (meta.decimals.has_value() && meta.decimals->value <= ::morph::math::kMaxDecimalPlaces) {
property["x-displayDecimals"] = std::uint64_t{meta.decimals->value};
}
}
}
// NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)

/// @brief Whether @p value satisfies every bound @p meta declares.
///
/// The comparisons run on the exact `math::Rational`, never on a `double`, so
Expand Down Expand Up @@ -2261,6 +2340,7 @@ void annotateBasicMemberProperty(glz::generic_u64& property, std::string_view na
property["x-i18nKey"] = std::string{fieldMeta->i18nKey};
}
annotateDeclaredBounds(property, *fieldMeta);
annotateDisplayUnit<Member>(property, *fieldMeta);
}

if constexpr (units::isQuantity<Member>) {
Expand Down
Loading
Loading