From 44be5efaa074f0fe11334c92038f4efa38697025 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Fri, 25 Sep 2026 16:13:34 +0200 Subject: [PATCH] forms: x-blankAs "empty" -- a string cleared in an edit form submits "" instead of being omitted DynamicForm omits a blank control from the payload. For an edit form prefilled from a stored record whose model reads an absent std::optional as "leave unchanged" and "" as "clear", that made a prefilled string impossible to clear: deleting the text sent nothing, and the stored value survived. A string property carrying "x-blankAs": "empty" now submits "" when its control is blank -- once the field is engaged since the last prefill or reset: prefilled with a string ("" included), or non-blank at any revalidate() since, whichever path wrote it (a control, setFieldValue, a slot's setValue). A field never prefilled and never typed into is still omitted, and a stored null does not engage it. `required` is unchanged: a required field left blank is still unfilled. The key is read only for kind "string" (nullable spellings included); every other kind ignores it. C++: FieldMeta gains `BlankAs blankAs{BlankAs::Omit}` (+ withBlankAs()), emitted as "x-blankAs": "empty" on a std::string / std::optional member only. Presentation only; the wire is unchanged. tst_DynamicFormBlankAs.qml (12 cases) asserts submitted bodies: never emitting "" reddens 4, ignoring engagement reddens 6. test_forms_blank_as.cpp pins the emission. forms_qml_logic: 409 passed, [forms] C++ cases all green on MSVC / Qt 6.11.1. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/spec/forms/forms.md | 44 +++- include/morph/forms/forms.hpp | 36 ++++ src/qt/forms/qml/DynamicForm.qml | 35 +++- src/qt/forms/tests/tst_DynamicFormBlankAs.qml | 188 ++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_forms_blank_as.cpp | 79 ++++++++ 6 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 src/qt/forms/tests/tst_DynamicFormBlankAs.qml create mode 100644 tests/test_forms_blank_as.cpp diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index f4622275e..bef9362b1 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -404,6 +404,7 @@ struct FieldMeta { std::optional multipleOf{}; // disengaged = any value std::string_view unit{}; // "" = no display unit (plain members only) std::optional decimals{}; // disengaged = no display precision + BlankAs blankAs{BlankAs::Omit}; // Empty = a cleared string submits "" (strings only) }; struct RecordMeasurement { @@ -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` 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` 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 @@ -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` 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 diff --git a/include/morph/forms/forms.hpp b/include/morph/forms/forms.hpp index 4209f66fb..f5b6831d9 100644 --- a/include/morph/forms/forms.hpp +++ b/include/morph/forms/forms.hpp @@ -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` @@ -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` 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. @@ -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`, @@ -2341,6 +2372,11 @@ void annotateBasicMemberProperty(glz::generic_u64& property, std::string_view na } annotateDeclaredBounds(property, *fieldMeta); annotateDisplayUnit(property, *fieldMeta); + if constexpr (std::is_same_v || std::is_same_v>) { + if (fieldMeta->blankAs == BlankAs::Empty) { + property["x-blankAs"] = std::string{"empty"}; + } + } } if constexpr (units::isQuantity) { diff --git a/src/qt/forms/qml/DynamicForm.qml b/src/qt/forms/qml/DynamicForm.qml index 54bcbf6ba..bbab4bbe8 100644 --- a/src/qt/forms/qml/DynamicForm.qml +++ b/src/qt/forms/qml/DynamicForm.qml @@ -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 @@ -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 @@ -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, 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" } }) } @@ -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 @@ -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) @@ -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) diff --git a/src/qt/forms/tests/tst_DynamicFormBlankAs.qml b/src/qt/forms/tests/tst_DynamicFormBlankAs.qml new file mode 100644 index 000000000..3aafd0c0f --- /dev/null +++ b/src/qt/forms/tests/tst_DynamicFormBlankAs.qml @@ -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` 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 remark; + // std::optional operatorName; std::optional plain; + // std::optional 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"}') + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fb9a115b5..077fdd340 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -114,6 +114,7 @@ add_executable(morph_tests test_forms_layout.cpp test_forms_field_bounds.cpp test_forms_display_unit.cpp + test_forms_blank_as.cpp test_forms_instance_constraints.cpp test_widget_hints.cpp test_datetime.cpp diff --git a/tests/test_forms_blank_as.cpp b/tests/test_forms_blank_as.cpp new file mode 100644 index 000000000..cb98e07a9 --- /dev/null +++ b/tests/test_forms_blank_as.cpp @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// `FieldMeta::blankAs` -- what a renderer submits for a string field the user +// left blank. `BlankAs::Empty` emits `"x-blankAs": "empty"` so an edit form +// can clear a stored `std::optional` (submit `""`) instead of +// omitting the member, which the model reads as "leave it unchanged". +// +// src/qt/forms/tests/tst_DynamicFormBlankAs.qml pins the renderer half +// against the same key. + +#include +#include +#include +#include +#include +#include + +// File-scope (not anonymous-namespaced): glaze's reflection needs a type with +// linkage. Same suppression, for the same reason, as +// tests/test_forms_display_unit.cpp. +// NOLINTBEGIN(misc-use-internal-linkage) + +struct FBARemarkAction { + std::optional remark; + std::string label; + std::optional untouched; + double weight = 0.0; + + static constexpr std::array fieldMetadata{ + morph::forms::FieldMeta{.field = "remark", .blankAs = morph::forms::BlankAs::Empty}, + morph::forms::FieldMeta{.field = "label"}.withBlankAs(morph::forms::BlankAs::Empty), + // Not a string: there is no "" to submit, so nothing is emitted. + morph::forms::FieldMeta{.field = "weight", .blankAs = morph::forms::BlankAs::Empty}, + }; +}; + +// NOLINTEND(misc-use-internal-linkage) + +namespace { + +glz::generic_u64 schemaDom(std::string const& schema) { + glz::generic_u64 dom{}; + REQUIRE_FALSE(schema.empty()); + REQUIRE_FALSE(glz::read_json(dom, schema)); + return dom; +} + +} // namespace + +TEST_CASE("Forms::FieldMeta::BlankAsEmptyOnAStringMemberEmitsTheKey", "[forms][field_meta][blank_as]") { + auto const dom = schemaDom(morph::forms::schemaJson()); + auto const& remark = dom["properties"]["remark"]; + REQUIRE(remark.contains("x-blankAs")); + CHECK(remark["x-blankAs"].get() == "empty"); + auto const& label = dom["properties"]["label"]; + REQUIRE(label.contains("x-blankAs")); + CHECK(label["x-blankAs"].get() == "empty"); +} + +TEST_CASE("Forms::FieldMeta::BlankAsIsOmittedByDefaultAndOnANonString", "[forms][field_meta][blank_as]") { + auto const dom = schemaDom(morph::forms::schemaJson()); + CHECK_FALSE(dom["properties"]["untouched"].contains("x-blankAs")); + CHECK_FALSE(dom["properties"]["weight"].contains("x-blankAs")); + constexpr morph::forms::FieldMeta kPlain{.field = "remark"}; + STATIC_CHECK(kPlain.blankAs == morph::forms::BlankAs::Omit); +} + +TEST_CASE("Forms::FieldMeta::BlankAsLeavesTheWireUntouched", "[forms][field_meta][blank_as]") { + // Presentation only: "" and nullopt still travel as they always have. + FBARemarkAction const cleared{.remark = std::string{}, .label = "L", .untouched = std::nullopt}; + std::string json{}; + REQUIRE_FALSE(glz::write_json(cleared, json)); + CHECK(json.contains(R"("remark":"")")); + FBARemarkAction decoded{}; + REQUIRE_FALSE(glz::read_json(decoded, R"({"remark":"","label":"L"})")); + REQUIRE(decoded.remark.has_value()); + CHECK(decoded.remark->empty()); + CHECK_FALSE(decoded.untouched.has_value()); +}