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
44 changes: 43 additions & 1 deletion docs/spec/forms/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ struct FieldMeta {
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
BlankAs blankAs{BlankAs::Omit}; // Empty = a cleared string submits "" (strings only)
};

struct RecordMeasurement {
Expand Down Expand Up @@ -610,6 +611,46 @@ struct RecordDensity {
Neither is checked server-side. Like `placeholder`, they are presentation;
the one gate that follows from `decimals` is the renderer's entry limit below.

### Clearing a string in an edit form — `blankAs`

A renderer omits a blank control from the payload, and for a create form that
is right. An **edit** form prefilled from a stored record is different when the
model reads an absent `std::optional<std::string>` as "leave unchanged" and
`""` as "clear". There, a user who deletes a prefilled remark sends nothing,
so the stored text survives. `FieldMeta::blankAs = BlankAs::Empty`
(or `.withBlankAs(BlankAs::Empty)`) emits `"x-blankAs": "empty"`, which makes the
blank control submit `""` — but only once the field is **engaged**:

```cpp
static constexpr std::array fieldMetadata{
FieldMeta{.field = "remark", .blankAs = BlankAs::Empty},
};
```

| Field state (since the last `prefill` / `resetFields`) | Blank control submits |
|---|---|
| Prefilled with a string, `""` included | `"remark": ""` |
| Non-blank at any point (typed into, `setFieldValue`, a slot's `setValue`), then cleared | `"remark": ""` |
| Never prefilled with a string and never non-blank; a stored `null` counts as not prefilled | nothing (omitted, as before) |

That rule has two consequences. A stored `""` round-trips: prefill → submit sends `""`.
A create form in which the user types and then clears a field sends `""` as well.

- **String members only.** The C++ side emits the key only on a
`std::string` / `std::optional<std::string>` member. `DynamicForm` reads it only
for a field of kind `string`, so a number, a closed set, a `Choice` or a
`Timestamp` ignores it. None of those has a `""` spelling.
- **`required` is unchanged.** A required field left blank is still unfilled,
and the form is not ready.
- **Presentation only.** Nothing changes on the wire or in the model. The key
only decides what a renderer assembles.

`tests/test_forms_blank_as.cpp` pins the emission.
`src/qt/forms/tests/tst_DynamicFormBlankAs.qml` (12 cases) pins the renderer
against submitted bodies. Making a cleared field never submit `""` reddens 4 of
those cases. Dropping the engagement rule, so an untouched field also submits
`""`, reddens 6.

### Field metadata is not a security control

`x-readonly` and `x-hidden` are presentation only. The field still travels in
Expand All @@ -634,8 +675,9 @@ member of the action at all.
| `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. |
| `x-blankAs` | property node (sibling of `$ref`) | string | `"empty"`, from `FieldMeta::blankAs = BlankAs::Empty`: once engaged, a blank string field submits `""` instead of being omitted. Emitted only for `Empty` on a `std::string` / `std::optional<std::string>` member. See [Clearing a string in an edit form](#clearing-a-string-in-an-edit-form--blankas). |

All twelve keys are additive and non-breaking, extending the renderer-contract
All thirteen 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
36 changes: 36 additions & 0 deletions include/morph/forms/forms.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@

namespace morph::forms {

/// @brief What a renderer submits for a string field the user left blank
/// (`FieldMeta::blankAs`).
enum class BlankAs : std::uint8_t {
/// Blank is "no value": the member is left out of the payload (the default).
Omit,
/// Blank, once the field was prefilled or edited, is an explicit `""`;
/// emitted as `"x-blankAs": "empty"`.
Empty,
};

/// @brief Per-field presentation overrides and scalar bounds: label, help,
/// placeholder, read-only, hidden, `minimum`/`maximum`/`multipleOf`,
/// and a plain member's display `unit`/`decimals`
Expand Down Expand Up @@ -307,6 +317,18 @@ struct FieldMeta {
// NOLINTNEXTLINE(readability-redundant-member-init) -- as `unit` above
std::optional<::morph::math::DecimalPlaces> decimals{};

/// @brief What a blank control submits for a `std::string` /
/// `std::optional<std::string>` member; `BlankAs::Empty` emits
/// `"x-blankAs": "empty"`.
///
/// An edit form prefilled from a stored record cannot otherwise clear an
/// optional string: a blank control is omitted, and an omitted member
/// reads as "leave it unchanged". With `Empty` a field the user emptied --
/// or one prefilled with `""` -- submits `""`; a field never prefilled
/// and never typed into is still omitted. **Ignored on any other member
/// type.**
BlankAs blankAs{BlankAs::Omit};

/// @brief Returns a copy with `placeholder` set to @p text.
/// @param text The placeholder hint.
/// @return The updated descriptor.
Expand Down Expand Up @@ -377,6 +399,15 @@ struct FieldMeta {
copy.decimals = places;
return copy;
}

/// @brief Returns a copy with `blankAs` set to @p mode.
/// @param mode What a blank control submits.
/// @return The updated descriptor.
[[nodiscard]] constexpr FieldMeta withBlankAs(BlankAs mode) const noexcept {
FieldMeta copy = *this;
copy.blankAs = mode;
return copy;
}
};

/// @brief Concept: a field type with an internal empty state (`Quantity`,
Expand Down Expand Up @@ -2341,6 +2372,11 @@ void annotateBasicMemberProperty(glz::generic_u64& property, std::string_view na
}
annotateDeclaredBounds(property, *fieldMeta);
annotateDisplayUnit<Member>(property, *fieldMeta);
if constexpr (std::is_same_v<Member, std::string> || std::is_same_v<Member, std::optional<std::string>>) {
if (fieldMeta->blankAs == BlankAs::Empty) {
property["x-blankAs"] = std::string{"empty"};
}
}
}

if constexpr (units::isQuantity<Member>) {
Expand Down
35 changes: 34 additions & 1 deletion src/qt/forms/qml/DynamicForm.qml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
// x-displayDecimals -> a plain number's display/entry precision: at most
// that many fraction digits are accepted, and the
// JSON-number encoding is kept (FieldMeta::decimals)
// x-blankAs: "empty" -> a string field cleared after a prefill or an edit
// submits "" instead of being omitted
// x-submitMode: "explicit" -> suppresses auto-submit-on-validity; renders
// an explicit Submit button (enabled only while ready)
// instead -- see "Explicit submit mode" below
Expand Down Expand Up @@ -140,6 +142,11 @@ Frame {
}

property var fieldValues: ({})
// Wire names of the `x-blankAs: "empty"` fields engaged since the last
// prefill or reset -- prefilled with a value, or non-blank at any
// revalidate() since. Only an engaged field submits "" when blank; an
// untouched one is omitted as before.
property var blankEngaged: ({})
property var fieldOptions: ({})
property var fieldUnits: ({})
property int optionsRevision: 0
Expand Down Expand Up @@ -764,7 +771,14 @@ Frame {
// SlotRegistry.byKind (see fieldKind).
kind: kind,
unitAscii: opt(extUnits.unitAscii, ""),
jsonType: jsonType
jsonType: jsonType,
// `x-blankAs: "empty"` (FieldMeta::blankAs) on a plain
// string member: once engaged, a blank control submits ""
// rather than leaving the member out -- how an edit form
// clears a stored std::optional<std::string>, where an
// omitted member means "leave it unchanged". Ignored on
// every other kind, whose blank has no "" spelling.
blankAsEmpty: kind === "string" && opt(raw["x-blankAs"], p["x-blankAs"]) === "empty"
}
})
}
Expand Down Expand Up @@ -1646,7 +1660,16 @@ Frame {
for (let i = 0; i < fields.length; ++i) {
const f = fields[i]
const text = (opt(fieldValues[f.name], "")).trim()
if (f.blankAsEmpty && text !== "")
blankEngaged[f.name] = true
const literal = fieldJsonLiteral(f)
// A cleared x-blankAs field is an explicit empty string. A
// required one keeps the ordinary gate: blank is still unfilled.
if (literal === null && text === "" && f.blankAsEmpty && blankEngaged[f.name] === true
&& !f.required && !isDynamicallyRequired(f.name)) {
parts.push(JSON.stringify(f.name) + ":\"\"")
continue
}
if (literal === null) {
if (text !== "" || f.required || isDynamicallyRequired(f.name)) {
ok = false
Expand Down Expand Up @@ -1739,6 +1762,7 @@ Frame {
form.withoutAutoSubmit(function() {
form.fieldValues = ({})
form.fieldUnits = ({})
form.blankEngaged = ({})
for (let i = 0; i < form.fields.length; ++i) {
const name = form.fields[i].name
const entry = form.findControl(form, "field_" + name)
Expand Down Expand Up @@ -1902,6 +1926,15 @@ Frame {
}
form.fieldValues = draft
form.fieldUnits = ({})
// A stored string -- "" included -- engages its x-blankAs field,
// so clearing it, or submitting it untouched, sends "".
const engaged = {}
for (let j = 0; j < form.fields.length; ++j) {
const g = form.fields[j]
if (g.blankAsEmpty && typeof values[g.name] === "string")
engaged[g.name] = true
}
form.blankEngaged = engaged
form.prefillRevision++
for (const parentName in form.dependents)
form.refreshDependents(parentName)
Expand Down
188 changes: 188 additions & 0 deletions src/qt/forms/tests/tst_DynamicFormBlankAs.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// SPDX-License-Identifier: Apache-2.0
//
// `"x-blankAs": "empty"` (FieldMeta::blankAs): a string field cleared after a
// prefill or an edit submits "" instead of being omitted.
//
// The motivating flow is an edit form over a stored record whose model reads
// an absent `std::optional<std::string>` as "leave unchanged" and "" as
// "clear". Without the key, deleting a prefilled remark omits the member and
// the stored text survives. Every case asserts the body the controller gets.

pragma ComponentBehavior: Bound

import QtQuick
import QtTest
import MorphForms

TestCase {
id: testCase
name: "DynamicFormBlankAs"
visible: true

QtObject {
id: mockController
signal replyReceived(string actionType, bool ok, string payload)
signal optionsReceived(string optionsAction, bool ok, string payload)

property int submitCount: 0
property string lastBody: ""

function submitIfValid(actionType, bodyJson) {
submitCount += 1
lastBody = bodyJson
replyReceived(actionType, true, JSON.stringify({ ok: true }))
}

function fetchOptions(optionsAction) {
optionsReceived(optionsAction, true, "[]")
}
}

function init() {
mockController.submitCount = 0
mockController.lastBody = ""
}

// `struct EditSample { std::int64_t id; std::optional<std::string> remark;
// std::optional<std::string> operatorName; std::optional<std::string> plain;
// std::optional<double> weight; std::string title; }` with
// blankAs = Empty on remark, operatorName, weight (ignored: not a string)
// and title (required, so the ordinary gate applies).
property var editSchema: ({
type: "object",
properties: {
id: { type: "integer", "x-order": 0, title: "Id" },
remark: { type: ["string", "null"], "x-order": 1, title: "Remark", "x-blankAs": "empty" },
operatorName: { anyOf: [{ type: "string" }, { type: "null" }], "x-order": 2, title: "Operator",
"x-blankAs": "empty" },
plain: { type: ["string", "null"], "x-order": 3, title: "Plain" },
weight: { type: ["number", "null"], "x-order": 4, title: "Weight", "x-blankAs": "empty" },
title: { type: "string", "x-order": 5, title: "Title", "x-blankAs": "empty" }
},
required: ["id", "title"]
})

Component {
id: editForm
DynamicForm { actionType: "T_EditSample"; schema: testCase.editSchema; controller: mockController }
}

Component {
id: textSlot
Item {
objectName: "remarkSlot"
property var field
property var setValue
}
}

Component {
id: registryComponent
SlotRegistry {}
}

function stored() {
return { id: 7, remark: "abc", operatorName: "Ann", plain: "keep", weight: 1.5, title: "T" }
}

function test_the_descriptor_flags_only_string_fields() {
const form = createTemporaryObject(editForm, testCase)
compare(form.fieldByName["remark"].blankAsEmpty, true)
compare(form.fieldByName["operatorName"].blankAsEmpty, true)
compare(form.fieldByName["plain"].blankAsEmpty, false)
compare(form.fieldByName["weight"].blankAsEmpty, false)
compare(form.fieldByName["title"].blankAsEmpty, true)
}

function test_a_prefilled_field_the_user_clears_submits_an_empty_string() {
const form = createTemporaryObject(editForm, testCase)
verify(form.prefill(stored()))
compare(mockController.submitCount, 0)
findChild(form, "field_remark").text = ""
compare(mockController.lastBody,
'{"id":7,"remark":"","operatorName":"Ann","plain":"keep","weight":1.5,"title":"T"}')
}

function test_a_field_without_the_key_is_still_omitted_when_cleared() {
const form = createTemporaryObject(editForm, testCase)
verify(form.prefill(stored()))
findChild(form, "field_plain").text = ""
compare(mockController.lastBody, '{"id":7,"remark":"abc","operatorName":"Ann","weight":1.5,"title":"T"}')
}

function test_an_untouched_never_set_field_is_omitted() {
const form = createTemporaryObject(editForm, testCase)
findChild(form, "field_id").text = "7"
findChild(form, "field_title").text = "T"
compare(mockController.lastBody, '{"id":7,"title":"T"}')
}

function test_a_field_typed_into_and_cleared_submits_an_empty_string() {
const form = createTemporaryObject(editForm, testCase)
findChild(form, "field_id").text = "7"
findChild(form, "field_title").text = "T"
findChild(form, "field_operatorName").text = "Bo"
findChild(form, "field_operatorName").text = ""
compare(mockController.lastBody, '{"id":7,"operatorName":"","title":"T"}')
}

function test_a_stored_empty_string_round_trips() {
const form = createTemporaryObject(editForm, testCase)
verify(form.prefillFromJson('{"id":7,"remark":"","title":"T"}'))
compare(form.ready, true)
form.submit()
compare(mockController.lastBody, '{"id":7,"remark":"","title":"T"}')
}

function test_a_stored_null_is_not_engaged() {
const form = createTemporaryObject(editForm, testCase)
verify(form.prefillFromJson('{"id":7,"remark":null,"title":"T"}'))
form.submit()
compare(mockController.lastBody, '{"id":7,"title":"T"}')
}

function test_a_non_string_field_ignores_the_key() {
const form = createTemporaryObject(editForm, testCase)
verify(form.prefill(stored()))
findChild(form, "field_weight").text = ""
compare(mockController.lastBody,
'{"id":7,"remark":"abc","operatorName":"Ann","plain":"keep","title":"T"}')
}

function test_a_required_field_left_blank_is_still_unfilled() {
const form = createTemporaryObject(editForm, testCase)
verify(form.prefill(stored()))
const before = mockController.submitCount
findChild(form, "field_title").text = ""
compare(form.ready, false)
compare(form.previewLine, "")
compare(mockController.submitCount, before)
}

function test_a_reset_disengages_the_field() {
const form = createTemporaryObject(editForm, testCase)
verify(form.prefill(stored()))
form.resetFields()
findChild(form, "field_id").text = "8"
findChild(form, "field_title").text = "U"
compare(mockController.lastBody, '{"id":8,"title":"U"}')
}

function test_a_new_prefill_disengages_what_the_previous_one_engaged() {
const form = createTemporaryObject(editForm, testCase)
verify(form.prefill(stored()))
verify(form.prefill({ id: 9, title: "V" }))
form.submit()
compare(mockController.lastBody, '{"id":9,"title":"V"}')
}

function test_a_slot_clearing_the_field_behaves_the_same() {
const registry = createTemporaryObject(registryComponent, testCase)
registry.byField("T_EditSample", "remark", textSlot)
const form = createTemporaryObject(editForm, testCase, { slotRegistry: registry })
verify(form.prefill(stored()))
findChild(form, "remarkSlot").setValue("")
compare(mockController.lastBody,
'{"id":7,"remark":"","operatorName":"Ann","plain":"keep","weight":1.5,"title":"T"}')
}
}
Loading
Loading