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
14 changes: 14 additions & 0 deletions .changeset/12292-grid-save-if-match.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/plugin-grid': patch
'@object-ui/plugin-list': patch
'@object-ui/components': minor
'@object-ui/types': minor
---

A grid's inline-edit save no longer overwrites a concurrent write without a word (objectui#12292). The grid's default row save and "Save All" now send each row's `updated_at` as `If-Match`, the way the record forms and the record page's inline edit already do. A stale grid edit is refused with `409` instead of landing with a `200`. The refusal opens the same conflict dialog the record page opens (`ConcurrentUpdateDialog`, offering Reload latest, Overwrite anyway or Cancel). After a save, the row's next save sends the token of the record the server answered with, so saving the same row twice does not conflict with your own first save.

The rows now carry that token. An inline-editable grid, and a list view in inline-edit mode, add `updated_at` to the query's `$select` even when no column shows it. Before, the token was missing from every row unless a column displayed it, so there was nothing to send.

"Save All" reports each row on its own. A row that is refused (a conflict you cancel, a validation error) stays pending and flagged, and the rows that saved are cleared, not reported as failed and not sent again. Nothing the server accepted is rolled back.

**Type change, additive.** `DataTableSchema.onBatchSave` may now resolve with `{ failed: Array<{ rowIndex, error }> }`, naming the rows that did not persist by the `rowIndex` they were handed. The table keeps and flags exactly those rows. A handler that resolves with nothing or rejects is read exactly as before, so existing handlers need no change.
7 changes: 7 additions & 0 deletions content/docs/components/complex/data-table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ When a host has switched editing on, a cell enters edit mode on double-click (or
a single click) and the table renders one of its built-in editors — text, number,
date — chosen from the column's `type`.

"Save All" hands every pending row to `onBatchSave` at once, and reads the outcome
per row. A batch is several writes, and some can land while others are refused. A
host that knows which rows did not persist resolves with `{ failed }`, naming each
by the `rowIndex` it was handed: the table keeps and flags exactly those rows, and
clears the rest. Resolving with nothing means every row persisted. A rejection
says nothing about which rows landed, so the table keeps and flags every row.

The host can also supply `renderCellEditor`, a function the table calls first for
every cell it is about to edit; it returns a node to use, or `null` to fall through
to the built-in editor for that column. This is how `object-grid` gives a `select`
Expand Down
6 changes: 6 additions & 0 deletions content/docs/plugins/plugin-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,12 @@ const persistence = (
keys: `onRowSave` for a single row, `onBatchSave` for many, `onCellChange` for
each committed cell edit, including one that leaves the value as loaded (only a
real change is staged)
- **A concurrent write is refused, not overwritten**: the default saves send each
row's `updated_at` as `If-Match`, and an editable grid asks for that column even
when no column shows it. A row someone else saved after it was read answers
`409`, which opens the record page's conflict dialog (Reload latest, Overwrite
anyway, Cancel). "Save All" reports each row on its own: a refused row stays
pending and flagged, and the rows that saved are cleared.

## TypeScript Support

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* 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.
*/

/**
* objectui#12292 — "Save All" reads the batch's outcome PER ROW.
*
* A batch is several independent writes: some can land while others are
* refused, and the server keeps the ones it accepted. The table used to read
* every batch as all-or-nothing, so one refused row kept every row pending and
* flagged, rows that had landed included, and a retry sent them again. An
* `onBatchSave` that knows which rows did not persist now resolves with
* `{ failed }`, and the table keeps exactly those rows. A rejection still reads
* as all-or-nothing, because it says nothing about which rows landed; the
* existing pins in `data-table-inline-edit.test.tsx` hold that half.
*/
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderComponent } from './test-utils';
// Registers the renderers at module scope, NOT inside a `beforeAll`; see
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

const schema = {
type: 'data-table' as const,
editable: true,
singleClickEdit: true,
columns: [
{ header: 'Task', accessorKey: 'name', editable: false },
{ header: 'Estimate', accessorKey: 'qty', type: 'number' },
],
data: [
{ id: '1', name: 'Ingest pipeline', qty: 60 },
{ id: '2', name: 'Warehouse schema', qty: 33 },
],
} as any;

type Entry = { rowIndex: number; changes: Record<string, any>; row: any };

/** Stage a new value in the Estimate cell of the given body row. */
function stage(container: HTMLElement, rowNumber: number, value: string) {
const row = container.querySelectorAll('tbody tr')[rowNumber] as HTMLElement;
const cell = row.querySelectorAll('td')[1] as HTMLElement;
fireEvent.click(cell);
const input = cell.querySelector('input') as HTMLInputElement;
fireEvent.change(input, { target: { value } });
fireEvent.blur(input);
}

describe('data-table — Save All reads a per-row batch outcome (objectui#12292)', () => {
it('keeps and flags only the rows the batch names as failed', async () => {
const onBatchSave = vi.fn(async (entries: Entry[]) => ({
failed: entries
.filter((e) => e.row.id === '2')
.map((e) => ({ rowIndex: e.rowIndex, error: new Error('Warehouse schema was refused') })),
}));
const { container, getByText, getByRole } = renderComponent({ ...schema, onBatchSave });

stage(container, 0, '61');
stage(container, 1, '31');
expect(getByText(/2 rows modified/)).toBeInTheDocument();
fireEvent.click(getByText(/Save All/));

await waitFor(() => expect(getByText(/1 row modified/)).toBeInTheDocument());
expect(onBatchSave).toHaveBeenCalledTimes(1);
expect(getByRole('button', { name: /Save All \(1\)/ })).toBeInTheDocument();
expect(container.textContent).toContain('Warehouse schema was refused');
// The refused row keeps its edit; the row that persisted holds none.
const rows = container.querySelectorAll('tbody tr');
expect(rows[1].textContent).toContain('31');
expect(rows[0].textContent).not.toContain('61');
});

it('clears every row when the outcome names no failure', async () => {
const onBatchSave = vi.fn(async () => ({ failed: [] }));
const { container, getByText, queryByText } = renderComponent({ ...schema, onBatchSave });

stage(container, 0, '61');
stage(container, 1, '31');
fireEvent.click(getByText(/Save All/));

await waitFor(() => expect(onBatchSave).toHaveBeenCalledTimes(1));
await waitFor(() => expect(queryByText(/modified/)).toBeNull());
expect(container.textContent).not.toContain('Save failed');
});

it('keeps every row when a failure names a row the batch never handed over', async () => {
const onBatchSave = vi.fn(async () => ({
failed: [{ rowIndex: 999, error: new Error('Unplaceable refusal') }],
}));
const { container, getByText } = renderComponent({ ...schema, onBatchSave });

stage(container, 0, '61');
stage(container, 1, '31');
fireEvent.click(getByText(/Save All/));

await waitFor(() => expect(container.textContent).toContain('Unplaceable refusal'));
expect(getByText(/2 rows modified/)).toBeInTheDocument();
});
});
54 changes: 45 additions & 9 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,15 @@ function extractSaveErrorMessage(error: unknown): string {
*/
type RowActionDef = NonNullable<DataTableSchema['rowActionDefs']>[number];

/**
* The per-row outcome `onBatchSave` may resolve with (objectui#12292), derived
* from the slot's declared return rather than restated here.
*/
type BatchSaveOutcome = Exclude<
Awaited<ReturnType<NonNullable<DataTableSchema['onBatchSave']>>>,
void
>;

/**
* The authoring shape of the built-in row Edit/Delete predicates — derived from
* `DataTableSchema`, never restated by hand (objectui#4354). Everything below
Expand Down Expand Up @@ -2163,30 +2172,57 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {

setIsSaving(true);
try {
// The pending key each handed-over `rowIndex` came from, so a per-row
// outcome can name its row back (objectui#12292).
const pendingKeyOf = new Map<number, number>();
const changesToSave = Array.from(pendingChanges.entries()).map(([rowIndex, changes]) => {
const globalIndex = (effectivePage - 1) * pageSize + rowIndex;
const row = sortedData[manualPagination ? rowIndex : globalIndex];
pendingKeyOf.set(globalIndex, rowIndex);
return { rowIndex: globalIndex, changes, row };
});

if (schema.onBatchSave) {
await schema.onBatchSave(changesToSave);

// `void` at runtime is `undefined`: the handler persisted every row.
const outcome = (schema.onBatchSave
? await schema.onBatchSave(changesToSave)
: undefined) as BatchSaveOutcome | undefined;
const failed = outcome?.failed ?? [];
const failedKeys = new Set<number>();
for (const entry of failed) {
const key = pendingKeyOf.get(entry.rowIndex);
// A failure naming a row this batch never handed over cannot be
// placed, so no row can be called persisted: fall back to the
// all-or-nothing reading below rather than clear an unsaved edit.
if (key === undefined) throw entry.error;
failedKeys.add(key);
}

// Clear all pending changes
setPendingChanges(new Map());

// Any staged editor left open (e.g. a lookup picker that keeps its widget
// open on pick) must exit edit mode now that every row is persisted —
// open on pick) must exit edit mode now that the batch has settled —
// otherwise the edited cell stays stuck showing the editor after 全部保存.
editingCellRef.current = null;
setEditingCell(null);
setEditValue('');
if (failedKeys.size > 0) {
// Per-row outcome: the rows that persisted are cleared, and only the
// rows that did not are kept and flagged. The server already accepted
// the others, so keeping them pending would report a landed write as
// a failed one, and a retry would send it again.
console.error('Failed to save rows:', failed.map((entry) => entry.error));
setPendingChanges(new Map(Array.from(pendingChanges).filter(([key]) => failedKeys.has(key))));
setErroredRows(failedKeys);
setSaveError(extractSaveErrorMessage(failed[0].error));
return;
}
// Clear all pending changes
setPendingChanges(new Map());
// Saved — clear any prior errors.
setErroredRows(new Set());
setSaveError(null);
} catch (error) {
// Batch is all-or-nothing here: keep every pending row, flag them all,
// and surface the reason instead of failing silently.
// A rejection says nothing about which rows landed, so it is read as
// all-or-nothing: keep every pending row, flag them all, and surface the
// reason instead of failing silently.
console.error('Failed to save batch:', error);
setSaveError(extractSaveErrorMessage(error));
setErroredRows(new Set(pendingChanges.keys()));
Expand Down
8 changes: 6 additions & 2 deletions packages/plugin-form/src/affordanceGrantMap-12082.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -805,8 +805,12 @@ const WRITE_SITES: Record<string, WriteSite> = {
...mapped(['listImport'], CONSOLE_LIST, 'packages/app-shell/src/views/importTargetFields.ts', EXCEL_IMPORT_BAR),
note: "one site, two mounts: the console list's Import and the AI build panel's spreadsheet import (`ExcelImportBar`) each read the row where they offer Import; HOST_GATED below holds every mount of the wizard to this list",
} as WriteSite,
'packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > defaultRowSave :: update': mapped(['listInlineEdit'], OBJECT_GRID),
'packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > defaultBatchSave :: update': mapped(['listInlineEdit'], OBJECT_GRID),
// The inline-edit saves (objectui#12292): `defaultRowSave` and
// `defaultBatchSave` both write through `writeRowGuarded`, the `If-Match`
// write; `resolveRowConflict` is the same save's Overwrite after a 409, re-keyed
// to the server's version. One affordance, the grid's `listInlineEdit` row.
'packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > writeRowGuarded :: update': mapped(['listInlineEdit'], OBJECT_GRID),
'packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > resolveRowConflict :: update': mapped(['listInlineEdit'], OBJECT_GRID),
'packages/plugin-grid/src/hooks/useBulkExecutor.ts :: useBulkExecutor > run > perRow :: delete': mapped(['rowDelete'], OBJECT_GRID),
'packages/plugin-grid/src/hooks/useBulkExecutor.ts :: useBulkExecutor > run > perRow :: update':
mapped(['rowEdit'], OBJECT_GRID),
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-grid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,12 @@ const persistence = (
- `onRowSave`: called when saving a single row
- `onBatchSave`: called when saving multiple rows at once
- `onCellChange`: called for each staged cell edit
- **A concurrent write is refused, not overwritten**: the default saves send each
row's `updated_at` as `If-Match`, and an editable grid asks for that column even
when no column shows it. A row someone else saved after it was read answers
`409`, which opens the record page's conflict dialog (Reload latest, Overwrite
anyway, Cancel). "Save All" reports each row on its own: a refused row stays
pending and flagged, and the rows that saved are cleared.

**Example Workflow:**
1. User edits multiple cells across different rows
Expand Down
Loading
Loading