…ed order, and a cell edit renumbers no line (objectui#12286)
Saving a master-detail edit form rewrote the stored position of lines the
user never touched. Two halves:
- Read: MasterDetailForm's readLinesOf asked for the lines with no sort, so
the grid drew the server's order. It now asks for them ascending by the
child's derived sort field, the record id breaking ties.
- Write: GridField stamped row[sortField] = index on every change, a cell
edit included, so lines with tied, empty or gapped positions were all
renumbered by one edit. A cell edit now renumbers nothing; adding,
removing, duplicating and moving a row still renumber. The save already
sent only the fields that differ from the read (objectui#10108).
Pins on a real MasterDetailForm over a fake server that returns the lines
out of stored order: one edit sends one update, one move sends only the
lines whose position changed, the grid opens in stored order, and tied or
empty positions open in id order with one edit sending one update.
Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
Fixes #12286
Clause-②: no
What was wrong
Saving a master-detail edit form wrote lines the user never touched: their stored
positionwas swapped and theirupdated_atbumped (the card reproduces it on the showcase's INV-1001 and INV-1002). Two halves, measured onmainatfd4872f:MasterDetailForm'sreadLinesOfread the lines with{ $filter, $top: 500 }and no sort, so the grid drew the server's order.GridField'semit()stampedrow[sortField] = indexon every change, a plain cell edit included. Once the grid drew a non-stored order, one edit gave every line a new position. Even in stored order, lines whose positions were tied (two at0, which the showcase field'sdefaultValue: 0produces), empty or gapped were all renumbered by one edit.The save itself already sent only what differs from the read:
buildMasterDetailEditBatchdiffs each row against theRowState.originalsnapshot withchangedFields, and emits no operation for an unchanged row (objectui#10108). The card's own capture shows it: the untouched line's update carried{position: 0}alone. So the pristine snapshot and the per-row diff the triage asked for already sit where the batch is assembled; what reached them was apositionthe grid had rewritten. The write half of this fix is therefore in the grid, not in the save.What changed
packages/plugin-form/src/MasterDetailForm.tsx:readLinesOfasks for the lines ascending by the entry's derivedsortField, the record id breaking ties:$orderby: [{ field: SORT_FIELD, order: 'asc' }, { field: 'id', order: 'asc' }]. That is the array formQueryParams.$orderbydeclares, andserializeOrderByin@object-ui/data-objectstacklowers it tosort=position,id. The sort field is known at read time: the load effect re-reads onceresolvedEntrieslands, and each resolved entry carriessortField. An entry with no derived sort field (a fully configured detail, or a child with no sort-named field) reads as before, with no$orderby.packages/fields/src/widgets/GridField.tsx:emit()takes arenumberflag, and the one cell-edit path (applyPatchon an existing row) passesfalse. Adding (Add, or typing into the trailing entry row), removing, duplicating and moving a row still renumber every row to its index. The renumber on a real reorder stays.GridFieldMetadata.sortFielddescription inpackages/types,content/docs/fields/grid.mdx,content/docs/plugins/plugin-form.mdxand the plugin-form README now say when the field is stamped and how the lines are read.packages/typesis touched for that description only: no type, export, prop, spec key, i18n key or accepted input is added or changed, and the member's type is untouched. HenceClause-②: no..changeset/12286-master-detail-stored-order.md: patch for@object-ui/plugin-form,@object-ui/fieldsand@object-ui/types.Pins
packages/plugin-form/src/MasterDetailForm.storedLineOrder-12286.test.tsxdrives the realMasterDetailFormand the real line grid, on the showcase invoice line's shape. The data source is a fake server: it returns the lines in its own order, which is not the stored one, unless the read asks for an order, and it refuses an$orderbyshape it does not read.{ quantity: 11 }, with no position.0,0) and empty (null,null) positions open in id order, and editing one line sends that line alone.packages/fields/src/widgets/GridField.sortFieldCellEdit-12286.test.tsxpins the widget half for tied, gapped, empty and absent positions. Its control is the entry-row add, which renumbers.Ablation: one leg per half
Each leg ran through objectstack's
scripts/ablation-replace.mjsin wrap mode. Each anchor hit once (1 → 0), and each leg was restored to the HEAD blob withgit diff HEADempty. Both subjects resolve tosrc: the test imports./MasterDetailForm, andvitest.config.mtsaliases@object-ui/fieldstopackages/fields/src. So no dist leg applies....(entry.sortFieldbecame...(false && entry.sortField): 5 failed, 5 passed. All five master-detail pins went red (Widget drawn first; C, A, B; Second, First), and the five grid pins stayed green.Gates
turbo run build --filter=@object-ui/plugin-form^... --filter=@object-ui/fields^... --concurrency=2: 11 of 11 tasks successful.pnpm exec vitest run --maxWorkers=2 packages/plugin-form/ packages/fields/plus the 12 suites outside them that render the master-detail form or the grid widget. The 12 were found by agit grepforMasterDetailForm,object-master-detail-form,LineItemsField,GridField,LineItemsPanelorrecord:line_itemsin a test file that imports@testing-library/react. Result: 446 files passed and 1 skipped (447); 7029 tests passed and 8 skipped. That run was on3d9e41c. The only later commit,040cd9d, types the new pin's fake server. On040cd9dthese five files passed 35 of 35: the two new pins,GridField.fieldKeys-11070,MasterDetailForm.inlineColumnsSortAmount-11144andmasterDetailDetailsMembers-8071.040cd9d:pnpm --filter @object-ui/fields --filter @object-ui/plugin-form --filter @object-ui/types run type-checkexited 0. All threetype-checkscripts echoed.tsc -p tsconfig.test.json --listFileslists each new test file.040cd9d: the 6 touched TS files have 0 errors. Per-file warnings equal the base's (26, 22, 3 and 0), and the two new tests have 0. This narrowing is a measurement, on three counts:eslint.config.js(one flat config), with all 6 files in the JSON output and none ignored.--format json.parserOptions.projectorprojectService) is configured, and no rule undereslint-rules/reads another file. So this diff cannot move a verdict on an untouched file.040cd9d, all exit 0:check-changeset-presence,check:changeset-claims,check:pending-changeset-literals,check:new-line-citations(0 new),check:control-bytes,check:test-path-roots,check:vi-mock-specifiers,check-doc-links,check-changeset-no-major,check-changeset-fixedandcheck:designer-field-key-parity.check:eager-closure, because the diff adds no import.check:readme-exports. It needs every package'sdistand answered "population COLLAPSED" with 25 packages unbuilt. The README edit is prose only, with no fenced block. CI runs it.Acceptance notes
$orderby), then again once it resolves. The second read supersedes the first. This predates the change, and the pins wait for the second read. Observation only; not filed.inlineMode: 'form',addRowViaFormsets none, so the line saves with the field's default or null. With the read now ordered, such a line sorts by that value and its id rather than last. The only example child with a sort-named field, the showcase invoice line, is grid-mode. Noted, not filed.plugin-grid/src/ObjectGrid.tsx; this PR does not touch it orplugin-form/src/occSave.tsx.Implemented in session
https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z(dispatched by thedomain:uiseat 1).Generated by Claude Code