Skip to content

Edit nested object/array properties in the namespaced property forms (#2027) - #2031

Merged
mstomar125 merged 2 commits into
mainfrom
issue-2027-namespaced-nested-props
Aug 11, 2026
Merged

mstomar125 merged 2 commits into
mainfrom
issue-2027-namespaced-nested-props

Conversation

@mstomar125

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2009. The recursive nested (object/array) property editor only reached GenericSchemaForm, which persists flat at properties[<field>]. The other two forms share the same field dispatch but write namespaced, so the flat modal couldn't be dropped in — it would have written nested edits to the wrong place, which is exactly why #2009 deferred them.

What changed

The modal is now store-agnostic. nested-property-modal.svelte no longer imports the store; it takes the current value and an onPersist callback from whichever form owns it. Each form supplies its own reader/writer, so all three share one editor and one modal while persisting to their own namespace:

Form Persists to
GenericSchemaForm properties[<field>] (flat — unchanged)
TaskSchemaForm properties.task[<field>]
SchemaPropertyForm properties[<nodeType>][<field>] (through its existing flat→nested migration, titleTemplate recompute and replaceProperties: true)

Both property forms now render leaves through the shared SchemaFieldLeaf instead of their own copies of the five-case dispatch. That removes both fall-throughs in one step — the "Boolean field (not yet implemented)" stub and "Unknown field type" — and is most of the ~360 deleted lines. Only the assignee combobox stays hand-rolled, having no shared equivalent.

Two behaviors were preserved deliberately: the enum default fallback (via a leafValue helper that resolves through the existing getEnumValue), and close-the-popover-on-date-pick, which moved into the shared leaf — so it now also fixes the generic form and the nested editor, where the calendar previously stayed open.

The summary button is shared too (nested-field-trigger.svelte), so a nested field looks identical in every form.

Tests

property-form-field-dispatch.test.ts is the crux: it renders each real form, clicks through to the modal, edits, and asserts the exact persisted properties bag — proving each namespace and that sibling keys survive ({ task: { estimate: 3, address: { street: '1 Main' } } }, { invoice: { total: 10, address: … } }, flat for generic), plus boolean rendering/persistence. Also new: 3 modal-contract tests and 3 nestedFieldSummary unit tests. #2009's tests stay green.

Full suite 4568 passed; lint + svelte-check clean.

Closes #2027.

🤖 Generated with Claude Code

mstomar125 and others added 2 commits August 11, 2026 12:28
…loses #2027)

The recursive nested-property editor only reached the generic schema form, which
persists flat at properties[<field>]. The task and per-type property forms share
the same field dispatch but write under properties.task[<field>] and
properties[<nodeType>][<field>], so the flat modal could not be dropped in — it
would have written nested edits to the wrong place.

Make the modal agnostic instead: it no longer touches the store, taking the
current value and an onPersist callback from whichever form owns it. Each form
supplies its own reader/writer, so all three share one editor and one modal while
persisting to their own namespace.

Both property forms now render leaves through the shared SchemaFieldLeaf rather
than their own copies of the five-case dispatch. That removes both fall-throughs
in one step: the "not yet implemented" boolean stub and "Unknown field type".
Only the assignee combobox stays hand-rolled, having no shared equivalent. Two
behaviors were preserved deliberately — the enum default fallback, and
close-the-popover-on-date-pick, which moved into the shared leaf and so now also
fixes the generic form and the nested editor, where the calendar used to stay
open.

The summary button is likewise shared, so a nested field looks the same in every
form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review found a data-loss path that predates this PR but only becomes destructive
now that nested values render here.

`getPropertyValue` documented three supported shapes but implemented two: it
never read the old flat location, even though `updateProperty` migrates from it
on the first write. For a leaf that only meant a blank input the user retypes.
For a nested object it meant the editor opened empty, and the first edit
persisted that empty object over the real one — with `replaceProperties`, so the
keys the user was never shown were simply gone. Reading the flat shape closes it,
and matches both the docblock and the equivalent bridge the task form already
has.

Also from review:
- Restored the empty-enum guard this form used to carry. It writes with
  `replaceProperties` and recomputes the title from the new value, so persisting
  an empty emission would blank both. Kept at the call site rather than in the
  shared leaf, since the other two forms never had it and may legitimately clear
  a value.
- The shared enum trigger humanizes a stored value the schema no longer declares,
  instead of rendering the raw key — the collapsed header already humanizes it,
  so the two disagreed.

Tests: an un-migrated nested value now renders its real key count instead of an
empty editor, a sibling key that was never displayed survives the migrating
write, and `replaceProperties` is asserted rather than assumed. All three fail
against the previous read path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mstomar125

Copy link
Copy Markdown
Contributor Author

APPROVE (self-review, mstomar125)

Adversarial review focused on the risk in a diff that deletes ~360 lines: silently dropped behavior. It went through the old and new versions of both property forms field by field and confirmed the substitutions are faithful — date parse/YYYY-MM-DD storage byte-identical, parseFloat || 0, placeholders, the enum default via leafValue, titleTemplate recompute, replaceProperties: true, the flat→nested migration, and the assignee combobox all intact; parsePropertyValue and the comboboxOpen pre-init loop genuinely dead once the leaf owns its popover state. Namespaces verified on both read and write for all three forms, and sibling survival is structural (deepMergeProperties + a complete namespace per caller), not incidental.

It found one major, fixed in c3d2351a: getPropertyValue documented a flat fallback it never implemented. That was harmless for a leaf (a blank input the user retypes) but destructive for a nested object — an un-migrated node opened an empty editor, and the first edit persisted that empty object over the real one under replaceProperties, taking keys the user was never shown. Now reads the flat shape, matching the docblock and the bridge the task form already had.

Also addressed: the empty-enum guard this form used to carry is restored at the call site (not in the shared leaf, since the other two forms never had it and may legitimately clear a value), and the shared enum trigger humanizes an undeclared stored value so the control stops disagreeing with the collapsed header that already humanized it.

Three new tests cover the migration path — real key count instead of an empty editor, a never-displayed sibling surviving the migrating write, and replaceProperties asserted rather than assumed. I verified all three fail against the previous read path and pass with the fix, so they genuinely lock the behavior in.

Known gap, non-blocking: no leaf-level test pins the date/number/enum onChange payloads directly — the equivalence there was established by reading old vs new rather than by assertion.

Full suite 201 files / 4571 tests; lint + svelte-check clean; gate green on both pushes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nested object/array property editing in task-schema-form and schema-property-form (namespaced persistence)

1 participant