Skip to content

feat: support for nested objects in frontmatter#546

Open
isabellabrookes wants to merge 27 commits intoMake-md:mainfrom
isabellabrookes:feat/frontmatter-recursive-objects
Open

feat: support for nested objects in frontmatter#546
isabellabrookes wants to merge 27 commits intoMake-md:mainfrom
isabellabrookes:feat/frontmatter-recursive-objects

Conversation

@isabellabrookes
Copy link
Copy Markdown

Summary

Adds proper rendering, editing, and space-schema synchronization for nested objects in frontmatter. Previously, nested objects rendered as [object Object] in cells, couldn't be edited inline, and their schemas didn't propagate when a property was synced to a space.

Before

Screenshot 2026-04-27 at 6 39 34 PM

After

demo.mp4

What changed

Rendering

  • Nested objects no longer leak as [object Object] — values are recursed through DataPropertyView so each sub-field gets the right cell type (link, date, boolean, multi-select, etc.) inferred from the live value.
  • Object cells start collapsed with a chevron toggle in the property row; click to expand. The property name + icon still open the property menu.

Editing

  • + Property button at the bottom of every editor (top-level and nested) to add new sub-fields at any depth.
  • "Change Type" added to the per-field menu so you can retype a sub-field.
  • Object-multi items are draggable to reorder. Each cell uses a per-instance drag namespace so drags don't bleed across nested cells.

Space synchronization

  • Removed the gate that prevented object properties from being added to a space (propertiesMenu).
  • When syncing an object property to a space, the column's schema is now derived recursively from the actual value so nested sub-fields propagate.
  • Schemas widen additively from any file in the space — typing a new sub-key into one file's frontmatter automatically adds it to the column schema, so other files in the space see the new sub-row.
  • One-shot retroactive backfill on space load catches columns that were synced before the live-widening hook existed.

Tests + CI

  • jest config and unit tests (~34) covering the pure utilities: type inference, recursive schema derivation, additive merge with upgrade rules, value coercion, JSON parsing.
  • GitHub Actions workflow runs the test suite on every PR and on pushes to main.

Test plan

  • Open a file with nested objects in frontmatter — sub-fields render with the right cell types instead of [object Object].
  • Click the chevron next to an object property — cell expands/collapses.
  • Expand a nested object → click + Property → pick a type → field is added to that level only.
  • In the per-field menu of a sub-field → Change Type → pick a new type → field type updates.
  • In an object-multi cell, drag an item header to reorder.
  • On a property whose value is an object, "Add Property to Context" → pick a space → the space's column has the full nested schema.
  • Add a new sub-key to an object property in any file in a space → open another file in the same space → the new sub-field appears as an empty row.
  • Open a space whose object columns were synced before this PR — after the first context reload, the column schemas are populated from existing row data.
  • Edit a sub-field inside an expanded object cell — cell stays expanded after save.
  • Add a brand-new object column to a space that already has data — sub-rows populate on other rows after the next reload (not just for newly-edited files).
  • Toggle a boolean inside a deeply-nested object, save, reopen — value persists as a real boolean (unquoted in YAML), checkbox stays in the right state.
  • After "Add Property to Context" on an object property whose nested values include booleans/numbers/dates, the column schema in the space's m_fields shows the right primitive types (not all "text").
  • Change Type on a sub-field with an existing value (e.g. text "42" → number) — value is converted, not wiped.

Notes

  • Bumps tsconfig.json target to es2018 (needed for the s regex flag used in textCacher).
  • Schema widening is additive only: it adds keys it sees and upgrades generic placeholders (text, option, option-multi) to more specific inferred types when the live value warrants it (e.g. a column inferred as option-multi upgrades to link-multi once the values are wikilinks). It never overrides a non-generic explicit type, so user choices via Change Type are sticky.
  • Cell renderers serialize values as strings; ObjectCell coerces them back to the native primitive shape (boolean / number) before storing so YAML stays clean.
  • Change Type tries to preserve the existing value by coercing to the new type; falls back to the new type's default only when the existing value can't be reasonably converted (e.g. "blah" → number).
  • Filtering/sorting on nested sub-keys (e.g. settings.notifications.verified) is not supported in this PR — the filter pipeline still operates on top-level cell values. Filed separately.

The 's' (dotAll) regex flag used by textCacher requires es2018+.
parseProperty now normalizes object values via a recursive walker that
flattens single-key {path: ...} link shapes and serializes the rest as
JSON. The text/tag/option/image fallback also stringifies object values
so cell renderers never receive a raw object that React would coerce
to '[object Object]'.
inferCellTypeForValue wraps detectPropertyType, mapping 'unknown' to
'text' so undeclared values still get a cell renderer.

deriveSchemaFromValue builds an ObjectType-shape schema from a sample
object, recursing into nested objects and arrays-of-objects so deep
sub-keys also get typed entries.
- Drop custom array/object chrome; route nested values through
  DataPropertyView so each sub-field gets its inferred cell type
  (links, dates, multi-options, etc.) and recurses through the
  existing cell pipeline.
- Memoize allProperties to avoid cascading invalidations in deeply
  nested ObjectCells.
- Move the collapse toggle into the property row (between field and
  value columns) and default cells to collapsed.
- Add an inline '+ Property' button at the bottom of every editor
  level to add sub-fields; remove the redundant button in the value
  column of DataPropertyView.
- Add 'Change Type' to the per-field menu, reusing showNewPropertyMenu
  with a new 'name' prop so the existing field name is pre-filled.
- Wire object-multi items as draggable via @dnd-kit/sortable. Drag
  handle is the group header only; a per-instance namespace stamps
  drag data so reorders don't bleed across nested cells.
Remove the gate that prevented 'object' properties from showing the
'Add Property to Context' option.

When syncing, derive the column's schema recursively from the actual
value (parsing JSON-stringified frontmatter values first), so nested
sub-fields propagate to other files in the space. If the column
already exists, update it instead of duplicating.
widenObjectSchemasForPath additively merges any new sub-keys observed
in a file's value into the column's schema (never overwrites or
removes). Hooks into updateContextWithProperties so every frontmatter
edit widens the schema as new sub-keys appear in any file.

backfillObjectSchemasForSpace iterates every row in a space's table
and applies per-path widening; runs once per session per space from
contextReloaded to populate columns that were synced before the live
hook existed.
defaultValueForType now returns {} for object and [] for object-multi
so ObjectEditor's '+ Property' seeds the right shape — without this,
'' was used and the live widening hook inferred 'text' for the entry.
…y multi

- Persist collapsed state in a module-level Map keyed by path+column
  name so it survives remounts. Without this, editing a sub-field
  collapsed the whole cell on save (parent refreshData rebuilds cols
  on every save which remounts DataPropertyView).
- Switch DataPropertyView keys in the children map from positional
  index to f.name so React preserves component identity across cols
  changes.
- '+ Property' inside ObjectEditor now uses defaultValueForType to
  seed the new field's value, so object and object-multi sub-fields
  carry the right shape into widening.
- Add an inline '+ <typeName>' button at the bottom of an
  object-multi cell so empty arrays can be seeded — without this,
  an empty object-multi rendered nothing and had no add affordance.
- Make insertMultiValue defensive against undefined type or value.
mergeObjectSchema now upgrades an existing 'text' entry to a more
specific inferred type when the live value clearly demands it
(boolean, number, date, link, object, etc.). Non-text types are
preserved — explicit user choices remain sticky.
Track backfilled object columns by '${spacePath}::${colName}' so
adding a brand-new object column to a space we've already loaded
this session still triggers a backfill scan. Previously, the
once-per-space gate skipped backfill for new columns added after
initial space load.
Cell renderers bubble values up as strings; coerceStringToType lifts
them back to native primitives based on the column type tag so YAML
stays clean and downstream inference doesn't misread them.

tryParseJSON is a non-throwing JSON.parse wrapper used wherever we
need to detect-and-parse a JSON-ish string.
Repeated saves of nested objects could leave values double-stringified
in frontmatter. unwrapStringifiedJSON peels back up to 5 layers so the
widening pass sees the real shape and infers types correctly. Applied
both to the top-level column value and to each sub-key in
mergeObjectSchema.
- Stringify primitives (booleans, numbers, dates) when passing
  initialValue to DataPropertyView so cell renderers that compare on
  string equality (BooleanCell etc.) read them correctly.
- handleUpdate now calls tryParseJSON for object/object-multi sub-fields
  to avoid cascading stringifications, and coerceStringToType for
  primitive sub-fields so a checkbox toggle writes a real boolean
  back into frontmatter instead of the string "false".
- saveFieldValue now passes the outer value with just the inner field
  replaced — without this, changing an inner cell wiped every sibling
  key in the parent object.
- Drop the editMode gate on showPropertyMenu so sub-property
  rename/change-type/delete works regardless of the host cell's edit
  mode (matches the +Property button which is also un-gated).
- Strip [[wikilink]] brackets via parseLinkString before passing the
  value to LinkCell, and pass the file path as source so PathCrumb
  can resolve relative paths and surface friendly names.
- Peel a once-serialized JSON-array string for link/option/object
  multi sub-fields so the unwrap applies uniformly even after a
  single round-trip through parseProperty.
mergeObjectSchema now upgrades a generic existing entry (text, option,
option-multi) when the live value clearly demands something more
specific (link, link-multi, etc.). Non-generic types are still
preserved so explicit user choices stay sticky.

Also exports mergeObjectSchema so unit tests can exercise the merge
logic directly.
Vertical alignment of the field icon, collapse toggle, and value cell
in mk-path-context-row reads better with center than flex-start now
that the row can host the new collapse chevron.
- jest.config.js with ts-jest, src as a module root so the same
  baseUrl-style imports work in tests.
- Tests for inferCellTypeForValue, deriveSchemaFromValue,
  coerceStringToType, tryParseJSON, defaultValueForType.
- Tests for parseProperty's object/text/tag fallbacks (covers the
  '[object Object]' regression and recursive serialization).
- Tests for mergeObjectSchema: additive merge, text-to-specific and
  option-multi-to-link-multi upgrades, explicit-type preservation,
  recursive nested merging.
Adds a GitHub Actions workflow that installs deps and runs jest on
every PR and on pushes to main.
Resolves to current npm-compatible versions within the existing
package.json ranges. No package.json changes; this just locks the
versions used locally so CI's npm ci has a matching lockfile.
Replace the New-Property-style modal with a direct type-picker that
fires saveType on each option click (saveOptions doesn't reliably fire
for single-select pickers). Also writes a default value for the new
type — mirrors PropertiesView.selectType so inference picks up the new
type even when the file's m_fields write is a no-op (orphaned
properties without a backing mdb).
Previously seeded the new type's default unconditionally on every
type change, wiping any prior data. Now we only seed when the field
is empty — existing values are kept and let the new cell renderer
coerce on read (BooleanCell parses 'true', NumberCell etc.).
When changing a sub-field's type, try to convert the existing value
into a shape that inference will recognise as the new type. Falls back
to the new type's default only when the field is empty or the value
can't be reasonably represented (e.g. "blah" → number).

Caveat: date strings always infer as date even after switching to
text — without persistent schema (orphaned properties), inference
wins and there's no way to override that without breaking the value.
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.

1 participant