Repository navigation
Grouped column blocks for arrays of small objects (label, alias, ...) with nesting support #4
Description
Goal
For arrays of small objects in JSON schemas, the canonical OSW shape label: [{text, lang}], also alias, simple coordinate pairs and similar, provide a tabular UX that:
- keeps everything in a flat, scrollable spreadsheet view (no drilldown modal for the common case),
- lets the user add or remove array elements per workbook via a column-level
[+ Add]and[- Remove]affordance, - maps cleanly onto
mwjson.util.flattenSchemaso schema, form, and sheet share one path vocabulary.
Grouped blocks are the "continue flattening" choice (see #2) for arrays of small objects: the array stays a flattened series of indexed leaves, but the user can mutate the length live. For arrays where we instead stop flattening (refs, primitives, complex objects), the cell holds the sub-tree and the cell editor is derived from the resolved sub-schema (json-editor or a specialised renderer per ui_preferences, see #1).
Builds on the existing Univer migration and the label-overlay and autocompletion features already in iframe-host.js.
What it looks like
For label: [{text, lang}] with flatten_options.array_length["label[*]"] = 2:
| A | B | C | D | E | F | |
|---|---|---|---|---|---|---|
| 1 | label[0] | label[1] | [+ Add label] | |||
| 2 | name | text | lang | text | lang | |
| 3 | Foo | Hello | en | Hallo | de | |
| 4 | Bar | Hi | en |
- Row 1 cells over each block are merged and coloured (stable colour hash per
pathPattern). - A custom column-header extension draws the
[+ Add label]button after the last block of each group. - Right-click on a block banner (row 1) opens a context menu: Add block after, Remove this block, Move left, Move right.
Relation to flattenSchema
Grouped blocks are not a separate flattening model. They are a visual grouping over consecutive expanded indices that flattenSchema already produces.
For label: [{text, lang}] with flatten_options.array_length["label[*]"] = 2, flattenSchema emits:
label[0].text,label[0].lang,label[1].text,label[1].lang
The grouped-blocks renderer:
- generates one binding per leaf (see Schema mapping: bind cells and columns to JSON schema properties #1),
- records that bindings sharing the same
pathPattern: "label[*]"form one visual group, ordered by array index, - renders the row-1 merged banner per block and the
[+ Add]button after the last block.
When the user clicks [+ Add], the editor:
- bumps
flatten_options.array_length["label[*]"]from2to3, - re-runs
mwjson.util.flattenSchemaover the category schema, - diffs against existing bindings, then emits two new bindings (
label[2].text,label[2].lang), - inserts the matching columns via
sheet.insertColumns(...)and copies validation rules from the previous block.
Remove is the inverse: drop array_length by 1, delete the bindings for the highest index, then sheet.deleteColumns(...).
Same machinery on every code path. Forms, scripts, batch fills all just call flatten / unflatten with the same options the workbook stores.
Nested arrays (array of objects containing an array of sub-objects)
OSW schemas regularly have shapes like measurements: [{label, values: [{value, unit}]}]. The inner array is itself a small-object array, so it can also be a grouped-blocks candidate. Three valid combinations, the user chooses per workbook via the boundary heuristic (#2) plus the schema-mapping dialog (#1):
Option A: outer grouped, inner stopped (recommended default)
Inner array is a cell that holds the array and opens the json-editor (or chips, per ui_preferences) on edit.
| A | B | C | D | E | F | |
|---|---|---|---|---|---|---|
| 1 | measurements[0] | measurements[1] | [+ Add measurement] | |||
| 2 | name | label | values | label | values | |
| 3 | Foo | Temp | [Edit 3 values...] |
Pressure | [Edit 1 value...] |
The values cell holds the whole inner array; double-click opens the editor for the inner sub-schema. array_length["measurements[*].values[*]"] is null, so flattenSchema keeps the inner array intact. Outer array_length["measurements[*]"] is mutable (grouped blocks).
flatten_options.array_length:
{ "measurements[*]": 2, "measurements[*].values[*]": null }This is the recommended default for any 2+ levels of array nesting: the visual complexity stays bounded to two header rows.
Option B: outer stopped, inner anywhere (sub-sheet)
| A | B | |
|---|---|---|
| 1 | name | measurements |
| 2 | Foo | [Sheet: measurements_Foo] |
| 3 | Bar | [Sheet: measurements_Bar] |
The outer cell links to a generated sub-sheet that itself has bindings for label and values of one measurements array. array_length["measurements[*]"] is null. Recommended when the outer array is large or has many leaf properties. Whether to render the cell as a sub-sheet link or as a generic json-editor is a ui_preferences choice (see #1).
Option C: outer grouped, inner grouped (deep nesting on one sheet)
| A | B | C | D | E | F | G | |
|---|---|---|---|---|---|---|---|
| 1 | measurements[0] | [+ Add measurement] | |||||
| 2 | label | values[0] | values[1] | [+ Add value] | |||
| 3 | name | value | unit | value | unit | ||
| 4 | Foo | Temp | 23.5 | C | 24.1 | C |
Three-row header. The outer block (measurements[0]) is its own coloured banner on row 1; the inner blocks (values[0], values[1]) get their own banner on row 2; leaf headers on row 3. [+ Add value] is per outer block, [+ Add measurement] is for the outer group.
flatten_options.array_length:
{ "measurements[*]": 1, "measurements[*].values[*]": 2 }Only practical for shallow rectangles (1-2 inner blocks per outer block, 2 leaf props per inner block). Beyond that, switch one level to a stop.
Bindings for nested grouped blocks
The block metadata is a list, outermost first. No render field (the cell editor is derived from the resolved sub-schema, see #1):
{
"cell_range": "C:C", "propertyPath": "measurements[0].values[0].value",
"block": [
{ "groupId": "measurements", "pathPattern": "measurements[*]", "index": 0, "bannerColor": "#e3f2fd" },
{ "groupId": "values", "pathPattern": "measurements[*].values[*]", "index": 0, "subProp": "value", "bannerColor": "#fff3e0" }
]
}The renderer draws one banner row per nesting level (outermost on top). Mutation operations propagate naturally: adding a measurements block creates one inner sub-block per the current inner array_length; removing a measurements block removes all its inner blocks; adding a values block on one outer block adds it to that outer block only (per measurements[i].values[*] length, which can differ between outer indices via the per-index override in array_length).
Why grouped blocks instead of "stop flattening and use a sub-editor"
The strategy framing in #2 says: for any array we can either continue flattening or stop. Both are valid for label-shaped arrays. Grouped blocks are the "continue" choice, optimised for:
- Each leaf is its own cell, so per-cell autocompletion, validation, formula references all work.
- Tabular comparison across rows is direct (eye-scan column equals scan one sub-property across all records).
- Bulk paste, fill-down, sort, filter all apply.
- A user editing 100 records of
Category:Materialrarely wants a modal for everylabel.
Drawbacks:
- Bounded by
array_lengthat any moment; user must hit[+ Add]to extend. - Columns get wider on schemas with many leaves per item, falls off above about 3 leaves.
- Nesting beyond 2 array levels gets visually ugly fast; default to Option A above.
The chooseFlatteningBoundary function in #2 picks grouped blocks automatically for items.type: object with up to 3 leaf properties; the user can override in the schema-mapping dialog.
Univer support today
No native outline or column-grouping API in the currently-installed Univer (@univerjs/sheets@0.24.x); the feature is on the Univer 1.0 roadmap but unreleased. Workaround uses existing primitives only:
sheet.insertColumns(at, count)andsheet.deleteColumns(at, count)(Facade API),range.merge()andrange.setBackground()for the banner rows,SheetColumnHeaderExtensionRegistryplus a customSheetColumnHeaderExtension(zIndex above 10) for the[+ Add]button,univerAPI.addEvent(Event.ColumnHeaderClick, ...)for the click handler.
When native outline ships, the group-banner rows can become real outline groups; nothing about the stored metadata changes.
Data model
Bindings stay one-per-leaf (matching flattenSchema output, consistent with #1). The visual grouping is recorded via the block field on each binding. Single-level case:
{
"metadata": {
"schemaMappings": {
"sheetId-0": {
"categories": ["Category:Item"],
"flatten_options": {
"array_index_notation": "[0]",
"notation": "dot",
"array_length": { "label[*]": 2 }
},
"bindings": [
{ "cell_range": "B:B", "propertyPath": "label[0].text",
"block": [{ "groupId": "label", "pathPattern": "label[*]", "index": 0, "subProp": "text", "bannerColor": "#e3f2fd" }] },
{ "cell_range": "C:C", "propertyPath": "label[0].lang",
"block": [{ "groupId": "label", "pathPattern": "label[*]", "index": 0, "subProp": "lang", "bannerColor": "#e3f2fd" }] },
{ "cell_range": "D:D", "propertyPath": "label[1].text",
"block": [{ "groupId": "label", "pathPattern": "label[*]", "index": 1, "subProp": "text", "bannerColor": "#e3f2fd" }] },
{ "cell_range": "E:E", "propertyPath": "label[1].lang",
"block": [{ "groupId": "label", "pathPattern": "label[*]", "index": 1, "subProp": "lang", "bannerColor": "#e3f2fd" }] }
]
}
}
}
}Nested case (measurements[*].values[*]): same shape with two entries in block, outermost first (see the bindings example above).
Reading data back
Same as everywhere else: build the propertyPath to value flat map, hand it to mwjson.util.unflatten. Expanded indices reassemble into arrays automatically, including nested ones:
const flatRow = {};
for (const b of bindings) flatRow[b.propertyPath] = sheet.getRange(rowIdx, columnOf(b)).getValue();
const record = mwjson.util.unflatten(flatRow, flatten_options);
// record.measurements is [{label: 'Temp', values: [{value: 23.5, unit: 'C'}, ...]}, ...]Empty cells skip via unflatten's defaults: a user who added 5 blocks but only filled 2 still gets a 2-element array.
Mutation operations
All wrap in one Univer command so undo/redo treats them atomically:
- Add block: bump
flatten_options.array_length[pathPattern]by 1, re-runflattenSchema, emit new bindings for the new index,sheet.insertColumns(...), copy validation from the previous block. - Remove block i: drop
array_lengthby 1, delete bindings whoseblock[level].index === i, renumberblock[level].indexfor higher indices,sheet.deleteColumns(...). - Move block left or right: swap
block[level].indexvalues between two adjacent blocks, swap the corresponding column ranges viainsertColumnsanddeleteColumns.
For nested grouped blocks, an outer add propagates one inner sub-block (per the current inner array_length); an outer remove drops all inner sub-blocks belonging to that outer index. Per-index overrides in array_length are supported (measurements[0].values[*] can be a different length than measurements[1].values[*]); the renderer treats inner block counts as outer-index-scoped.
UX subquestions to settle
- How to render the
[+ Add]button: a header-extension drawn shape (canvas), or a real DOM button positioned over the header? Canvas is simpler but interaction is rougher (no native focus or hover styling). - Per-block remove affordance: visible always (small "x" on the banner), or only on hover or right-click?
- Visual indication when a row has fewer filled blocks than the header suggests: gray-out unfilled cells, or leave them blank?
- What happens when the user inserts a regular column inside a block via the toolbar? Reject, or re-detect and reconfigure the block boundary?
- Initial block count when generating from schema: 1,
minItems, or 2? - For nested grouped blocks, can inner block counts differ between outer blocks (per-index
array_lengthoverride), or are they always synchronised? - When a sub-prop has
items.format: uuid(e.g. a nested ref inside each block), that sub-column should auto-wire an autocompletion config, confirm the trigger.
Acceptance criteria
- Schema bindings for arrays of small objects produce grouped column blocks at template-generation time (see Template generator: pre-fill a sheet from one or more Category schemas #2)
- Two-row header renders for single-level grouped blocks: row 1 is merged banner per block, row 2 is leaf headers
- N+1-row header renders for N levels of nested grouped blocks: one banner row per nesting level, leaf headers at the bottom
- Banner colours are stable per
pathPatternacross reloads -
[+ Add label]affordance bumpsflatten_options.array_length["label[*]"], emits new bindings, inserts columns, copies validation rules -
[+ Add value]affordance on a nested grouped block bumps that block's innerarray_length(per outer index if scoped) and inserts the matching columns within the outer block - Right-click on a block banner offers: Add after, Remove, Move left, Move right
- Block index labels (
label[0],label[1],measurements[0].values[0], ...) renumber automatically after add, remove, or move - Undo/redo treats each add, remove, or move as a single step (including the cascading effect of outer add/remove on inner blocks)
- JSON export uses
mwjson.util.unflattenand reconstructs nested arrays correctly, skipping empty blocks - Load and save round-trips
flatten_options.array_lengthand the per-bindingblockchain inmetadata.schemaMappings - When the autocompletion plumbing matches a sub-property's range, autocompletion auto-applies to that sub-column across all blocks at the relevant nesting level
Related
- Schema mapping: bind cells and columns to JSON schema properties #1, schema mapping and the framework (this issue extends the binding model with the
blockchain) - Template generator: pre-fill a sheet from one or more Category schemas #2, template generator and boundary-choice heuristic (selects grouped blocks via
chooseFlatteningBoundary; for nested arrays, defaults to Option A above) mwjson.util.flattenSchema/flatten/unflatten, the canonical flattening API this builds on
Activity