Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/12286-master-detail-stored-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/plugin-form': patch
'@object-ui/fields': patch
'@object-ui/types': patch
---

Saving a master-detail edit form no longer rewrites the position of lines the
user did not touch (objectui#12286).

The edit form read each collection's lines with no sort, so its line grid drew
them in whatever order the server returned, and the grid stamped every line's
index into the child's sort field (`position`, `sort_order`, …) on any change.
Editing one cell of one line therefore saved a new position onto its untouched
siblings, swapping their stored order and bumping their `updated_at`.

- `MasterDetailForm` now reads the lines ascending by the child's derived sort
field, the record id breaking ties, so the grid opens in stored order.
- The line grid (`field:grid` with a `sortField`) renumbers only when a row is
added, removed, duplicated or moved. A cell edit leaves every row's position
as it was, so lines whose stored positions are tied, empty or gapped are not
renumbered by an edit either.

Editing one line's field now sends exactly one update, and moving a line sends
only the lines whose position changed. The `GridFieldMetadata.sortField`
description says when the grid stamps the field.
8 changes: 5 additions & 3 deletions content/docs/fields/grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ The field-level keys, each read under exactly this one spelling:
the save writes the sum to. No total shows when it is unset.
- `addLabel` — the Add button's label.
- `sortField` — the name of a field on each **row** that the grid stamps with the
row's index (0, 1, 2, …) on every change, so the order a drag-reorder leaves is
saved with the rows. It is not one of the columns: a column of that name would
have its typed value overwritten. Rows carry no position when it is unset. When
row's index (0, 1, 2, …) whenever a row is added, removed, duplicated or moved,
so the order a drag-reorder leaves is saved with the rows. Editing a cell moves
no row and leaves every row's position as it was. It is not one of the columns:
a column of that name would have its typed value overwritten. Rows carry no
position when it is unset. When
a master-detail form derives a detail's grid from the child object (it does
unless the detail names its relationship field and types every column), it sets
this to the child's first field named `position`, `sort_order`, `sequence`,
Expand Down
7 changes: 7 additions & 0 deletions content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,13 @@ Remove asks the delete grant on an edit form only: a create form saves the paren
and one create per line, so a line removed before that first save is a draft,
never a server delete, and stays removable for a caller who may only create.

An edit `MasterDetailForm` reads each collection's lines in their stored order:
ascending by the child's sort field (the field its grid stamps with each line's
position), the record id breaking ties, so lines that share a position, or have
none, open in the same order every time. Its save sends a line only when one of
its values differs from that read: editing a cell sends that line alone, and
moving a line sends only the lines whose position changed.

## Examples

### Form with Validation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@
* `totalField` names); `amount_field` and `amountField` change nothing;
* - `addLabel` — declared, and it labels the Add button;
* - `sortField` — declared (round 9): it names the CHILD field each row is
* stamped with its index in, on every change, so a drag-reorder persists;
* with no key the rows carry no position;
* stamped with its index in, on every change that adds, removes or moves a
* row, so a drag-reorder persists; with no key the rows carry no position
* (a cell edit leaves positions alone: `GridField.sortFieldCellEdit-12286`);
* - `allow_duplicate` and `show_line_numbers` — retired under ADR-0049 (no
* producer in either repository wrote them): the widget keeps the
* behaviour their defaults gave, a duplicate action whenever rows can be
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* A cell edit leaves every row's `sortField` as it was (objectui#12286).
*
* The grid stamped `row[sortField] = index` on EVERY change, a plain cell edit
* included. Whenever the stored positions were not already 0, 1, 2, … in the
* order drawn (two rows tied on one position, rows with none, gaps), editing
* one cell gave every row a new position, and a master-detail save then wrote
* each of those rows although the user had touched one. A cell edit moves no
* row, so it renumbers nothing; adding, removing, duplicating and moving a row
* still renumber (the drag-reorder row is pinned in
* `GridField.fieldKeys-11070.test.tsx`).
*
* The pins read the rows the grid hands to `onChange`, which is everything a
* host persists.
*/

import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import type { GridFieldMetadata } from '@object-ui/types';
import { GridField } from './GridField';

const field: GridFieldMetadata = {
type: 'grid',
name: 'lines',
columns: [{ name: 'description', label: 'Description', type: 'text' }],
sortField: 'position',
};

type Row = Record<string, unknown>;

/** Render `rows`, type `value` into row `row`'s Description cell, and return what the grid handed back. */
function typeInto(rows: Row[], row: number, value: string): Row[] {
const onChange = vi.fn();
render(<GridField value={rows} onChange={onChange} field={field} />);
fireEvent.change(screen.getAllByLabelText('Description')[row], { target: { value } });
expect(onChange).toHaveBeenCalledTimes(1);
return onChange.mock.calls[0][0];
}

describe('GridField: a cell edit renumbers no row (objectui#12286)', () => {
it.each([
['tied', [{ description: 'A', position: 0 }, { description: 'B', position: 0 }]],
['gapped', [{ description: 'A', position: 10 }, { description: 'B', position: 20 }]],
['empty', [{ description: 'A', position: null }, { description: 'B', position: null }]],
['absent', [{ description: 'A' }, { description: 'B' }]],
])('%s positions: editing row 2 changes its cell and every position stays as read', (_label, rows) => {
const next = typeInto(rows, 1, 'B2');
expect(next).toEqual([rows[0], { ...rows[1], description: 'B2' }]);
});

it('CONTROL: typing into the trailing entry row ADDS a line, and an add renumbers every row', () => {
const next = typeInto([{ description: 'A', position: 0 }, { description: 'B', position: 0 }], 2, 'C');
expect(next.map((r) => [r.description, r.position])).toEqual([
['A', 0],
['B', 1],
['C', 2],
]);
});
});
16 changes: 11 additions & 5 deletions packages/fields/src/widgets/GridField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -770,8 +770,13 @@ function GridFieldBody({
// census having found no producer of either (objectui#11070 round 8).
const totalField: string | undefined = cfg.totalField;
// When set, the row's order is persisted by stamping `row[sortField] = index`
// on every change — so drag-reorder survives a reload (the app adds a numeric
// position field and lists sort by it). Without it, reorder is order-of-entry.
// on every change that adds, removes or moves a row — so drag-reorder
// survives a reload (the app adds a numeric position field and lists sort by
// it). Without it, reorder is order-of-entry. A cell edit moves no row, so it
// leaves every row's position as it was read (objectui#12286): renumbering
// there wrote a position onto lines the user never touched whenever the
// stored positions were not already 0, 1, 2, … in the order drawn (tied,
// empty, or gapped).
// Declared on `GridFieldMetadata`; its producer is `deriveDetail`
// (`@object-ui/plugin-form`, through `MasterDetailForm`), which picks the
// child object's `position` / `sort_order` / … field (objectui#11070 round 9).
Expand All @@ -782,9 +787,10 @@ function GridFieldBody({
// (objectui#11070 round 8).
const allowReorder = cfg.allowReorder !== false && !readonly && !disabled;

/** Hand the next rows out; `renumber: false` is a cell edit, which moves no row. */
const emit = useCallback(
(next: Row[]) => {
onChange?.(sortField ? next.map((r, i) => ({ ...r, [sortField]: i })) : next);
(next: Row[], renumber = true) => {
onChange?.(sortField && renumber ? next.map((r, i) => ({ ...r, [sortField]: i })) : next);
},
[onChange, sortField],
);
Expand All @@ -809,7 +815,7 @@ function GridFieldBody({
emit([...rows, computeRow(columns, { ...blankRow(), ...patch }, tenantCurrency)]);
return;
}
emit(rows.map((r, i) => (i === rowIdx ? computeRow(columns, { ...r, ...patch }, tenantCurrency) : r)));
emit(rows.map((r, i) => (i === rowIdx ? computeRow(columns, { ...r, ...patch }, tenantCurrency) : r)), false);
},
[rows, columns, maxRows, blankRow, emit, tenantCurrency],
);
Expand Down
7 changes: 7 additions & 0 deletions packages/plugin-form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,13 @@ through the wizard, and the parent operation of a master-detail form, whose
header is a simple or a `tabbed` form. A master-detail child row already worked
this way (objectui#10108), and all of them use the same comparison.

A master-detail edit reads each collection's lines in their stored order:
ascending by the child's sort field (the one its line grid stamps with each
line's position), the record id breaking ties (objectui#12286). The grid
renumbers that field only when a line is added, removed, duplicated or moved; a
cell edit leaves every line's position as it was read. So editing one line
sends one update, and moving a line sends only the lines whose position changed.

Before that comparison, every layout strips what a form never writes: the
server-owned columns (record identity, audit provenance, ownership and tenancy
— the roster in `sanitize.ts`, plus any field the object marks `system`),
Expand Down
Loading
Loading