Skip to content

Commit adbda1b

Browse files
os-litantclaude
andauthored
feat(types,components,plugin-grid): carry the staged row across the renderCellEditor seam as pendingRow (#7241)
`renderCellEditor`'s context on `DataTableSchema` gains a seventh member, `pendingRow` — the persisted `row` shallow-merged with the row's staged, unsaved edits. `row` keeps meaning the persisted record; neither is redefined. `data-table` builds it from `pendingChanges`, identity-stable per row while nothing new is staged, so a dependent widget's identity-keyed fetch effects do not re-fire on every table re-render. `ObjectGrid` scopes a `dependsOn` inline editor by `ctx.pendingRow ?? ctx.row`, so a parent edited in the same row re-scopes the child before anything is saved, and the interim marker that named the follow-up is gone with the interim. The exact-shape pin in `@object-ui/types` is extended, not weakened, and gains a control proving it can tell the seventh member's presence from its absence. Test 4 of the grid fixture flips from pinning the saved-row staleness to asserting that the staged parent scopes the child, with its own proof that the staging landed and nothing was saved. The zod mirror encodes `renderCellEditor` as `z.function()` with no parameter shape; only its description records the delta. The docs snippet and prose for `data-table` carry the new member. Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92c65aa commit adbda1b

11 files changed

Lines changed: 222 additions & 89 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/components': minor
3+
---
4+
5+
feat(data-table): pass `pendingRow` to the host cell editor — the row with its staged edits merged over it (#7188)
6+
7+
`data-table` already computed each row's `pendingChanges` entry in the row loop that
8+
renders the editor; it now hands the injected editor that row merged with those staged
9+
values as `pendingRow`, next to the persisted `row`. Nothing about `row`, `value`,
10+
`stage`, `commit` or `cancel` changed. A host editor that scopes itself by a sibling
11+
field can read `pendingRow` and follow an edit the user has made but not yet saved.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
---
4+
5+
Fix: a `dependsOn` lookup edited inline follows a parent edited in the same row **before**
6+
it is saved (#7188, finishing #7165).
7+
8+
#7165 shipped an explicitly-labelled interim: the grid's inline editor scoped a
9+
`dependsOn` picker by `ctx.row`, the **saved** record. Edit the parent cell, do not save,
10+
open the child — and the child still listed candidates for the parent's persisted value
11+
(or stayed gated if that value was empty). The grid now scopes by `ctx.pendingRow ?? ctx.row`,
12+
where `pendingRow` is the row merged with its staged, unsaved edits, so picking a parent
13+
re-scopes the child immediately — the form's live-record semantics from #2216. The interim
14+
marker that named #7188 is gone with the interim.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@object-ui/types': minor
3+
---
4+
5+
feat(types): `renderCellEditor`'s context gains `pendingRow` — the persisted row merged with its staged, unsaved edits (#7188)
6+
7+
The inline cell editor's context on `DataTableSchema` (declared by #6882) carried the row
8+
once, as `row` — the **persisted** record. A widget that scopes itself by a sibling field
9+
(a `dependsOn` lookup) had no way to see a parent that was edited in the same row but not
10+
yet saved, so it kept listing candidates for the old parent.
11+
12+
The context now carries the row twice, deliberately:
13+
14+
- `row` — unchanged: the persisted record, what the data source last returned.
15+
- `pendingRow` — **new**: `row` shallow-merged with the row's staged, unsaved edits. The
16+
same object as `row` when nothing is staged.
17+
18+
`row` was **not** redefined to mean the merged record — that would silently change an
19+
already-published member, and a host that needs the persisted value would have lost its
20+
only source. Both are addressable. The zod mirror's `renderCellEditor` is `z.function()`
21+
and encodes no parameter shape; its description records the delta and names the member
22+
on `DataTableSchema` as the authority. The #6882 exact-shape pin was extended (not
23+
weakened) in the same change, with a control proving the pin can tell the seventh member's
24+
presence from its absence.

‎content/docs/components/complex/data-table.mdx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ interface DataTableSchema {
4949
singleClickEdit?: boolean; // Enter edit mode on single click (default: false)
5050
renderCellEditor?: (ctx: { // Host-supplied editor widget; null -> built-in input
5151
column: any;
52-
row: any;
52+
row: any; // the persisted record
53+
pendingRow: any; // row + this row's staged, unsaved edits (#7188)
5354
value: any;
5455
stage: (v: any) => void;
5556
commit: (v?: any) => void;
@@ -124,6 +125,13 @@ affordances the built-in editors have: Enter commits from a single-line input,
124125
Escape cancels, and a click outside commits. Use `stage` to record a value while
125126
staying in edit mode, `commit` to save, and `cancel` to discard.
126127

128+
The context carries the row twice, on purpose: `row` is the persisted record, and
129+
`pendingRow` is that record with the row's staged, unsaved edits merged over it
130+
(the same object as `row` when nothing is staged). A widget that scopes itself by
131+
a sibling field — a `dependsOn` lookup — should read `pendingRow`, so a parent
132+
edited in the same row re-scopes the child before anything is saved; `row` stays
133+
the place to read what the data source last returned.
134+
127135
## Examples
128136

129137
### Product Inventory

‎packages/components/src/renderers/complex/data-table.tsx‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,33 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
981981
const [editValue, setEditValue] = useState<any>('');
982982
// Track pending changes for multi-cell editing: rowIndex -> { columnKey -> newValue }
983983
const [pendingChanges, setPendingChanges] = useState<Map<number, Record<string, any>>>(new Map());
984+
985+
// objectui#7188 — the row merged with its STAGED edits, handed to the host
986+
// editor as `pendingRow` next to the persisted `row`. Cached per row object
987+
// and rebuilt whenever `pendingChanges` is replaced (every stage / save /
988+
// cancel builds a new Map), so the merged record keeps its IDENTITY across
989+
// renders that stage nothing. That matters: `LookupField` keys its
990+
// `dependentFilter` / `popoverFilter` memos on the identity of the record it
991+
// is handed, and its recent-ids effect keys on `popoverFilter`, so a fresh
992+
// object per render would re-issue that query on every table re-render while
993+
// the picker is open (`useRecordQuery` itself is immune — it keys on a JSON
994+
// signature). `row` is identity-stable for the same reason; a row with
995+
// nothing staged is handed `row` itself.
996+
const pendingRows = useMemo(() => {
997+
const byRow = new WeakMap<object, any>();
998+
return {
999+
/** `row` merged with its staged edits — or `row` itself when it has none. */
1000+
of(row: any, rowIndex: number): any {
1001+
const changes = pendingChanges.get(rowIndex);
1002+
if (!changes || row === null || typeof row !== 'object') return row;
1003+
const hit = byRow.get(row);
1004+
if (hit !== undefined) return hit;
1005+
const merged = { ...row, ...changes };
1006+
byRow.set(row, merged);
1007+
return merged;
1008+
},
1009+
};
1010+
}, [pendingChanges]);
9841011
const [isSaving, setIsSaving] = useState(false);
9851012
// Last save failure message (server validation text, etc.) shown in the
9861013
// toolbar; null when the last save attempt succeeded or nothing's been saved.
@@ -1025,8 +1052,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
10251052
//
10261053
// The listener is still needed, for a different reason: NOTHING EVER HANDS
10271054
// THE WIDGET ONE. The wrapper below carries `onKeyDown` alone, and the
1028-
// context object `renderCellEditor` receives — `{ column, row, value, stage,
1029-
// commit, cancel }` — has no DOM-props slot to put an `onBlur` in.
1055+
// context object `renderCellEditor` receives — `{ column, row, pendingRow,
1056+
// value, stage, commit, cancel }` — has no DOM-props slot to put an `onBlur` in.
10301057
//
10311058
// ⚠️ The second half of that reason is GONE (objectui#6909). The in-repo
10321059
// factory behind the seam, `@object-ui/fields`' `FieldEditWidget`, used to
@@ -2320,6 +2347,12 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
23202347
const node = injectEditor({
23212348
column: col,
23222349
row,
2350+
// The persisted row with this row's staged,
2351+
// unsaved edits merged over it (objectui#7188);
2352+
// the SAME object as `row` when nothing is
2353+
// staged, so `row === pendingRow` reads as
2354+
// "clean" on the host side.
2355+
pendingRow: rowHasChanges ? pendingRows.of(row, rowIndex) : row,
23232356
value: editValue,
23242357
stage: stageEdit,
23252358
commit: (v?: any) => saveEdit(true, v),

‎packages/plugin-grid/src/ObjectGrid.tsx‎

Lines changed: 35 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -860,11 +860,16 @@ export type ObjectGridDataTableSchemaHolds = {
860860
* REDUNDANT since objectui#6882 (2026-08-30) — `DataTableSchema` declares this
861861
* key itself now, with a shape measured `Equal` to this one. `data-table`
862862
* calls it to render a host cell editor; returning `null` falls through to the
863-
* built-in text / number / date inputs.
863+
* built-in text / number / date inputs. objectui#7188 added `pendingRow` (the
864+
* persisted `row` merged with the row's staged, unsaved edits) upstream, and
865+
* this copy carries it too so the seam's intersection stays INERT — `Equal`
866+
* in both directions, as #7196 measured — rather than becoming a second,
867+
* narrower signature that a `pendingRow`-reading host would trip on.
864868
*/
865869
renderCellEditor?: (ctx: {
866870
column: any;
867871
row: any;
872+
pendingRow: any;
868873
value: any;
869874
stage: (v: any) => void;
870875
commit: (v?: any) => void;
@@ -4015,7 +4020,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
40154020
// handing DataTable an editor factory would leave the built-in fallback
40164021
// editors as the only reachable ones if any future path re-opened the mode.
40174022
renderCellEditor: inlineEditable
4018-
? (ctx: { column: any; row: any; value: any; stage: (v: any) => void; commit: (v?: any) => void }) => {
4023+
? (ctx: { column: any; row: any; pendingRow: any; value: any; stage: (v: any) => void; commit: (v?: any) => void }) => {
40194024
const fieldDef = (objectSchema as any)?.fields?.[ctx.column?.accessorKey];
40204025
if (!fieldDef || !hasFieldEditWidget(fieldDef.type)) return null;
40214026
const discrete = DISCRETE_EDIT_TYPES.has(fieldDef.type);
@@ -4035,50 +4040,35 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
40354040
field={field}
40364041
value={ctx.value}
40374042
onChange={(v: any) => (discrete ? ctx.commit(v) : ctx.stage(v))}
4038-
// ⚠️ INTERIM (objectui#7165) — the SAVED row, not the staged one.
4043+
// The record a dependent widget scopes itself by (objectui#7165,
4044+
// finished by objectui#7188). `LookupField` resolves
4045+
// `dependentValues ?? ctx.formValues ?? ctx.data ?? {}`, and only
4046+
// the FIRST link is suppliable by any host: `SchemaRendererContextType`
4047+
// declares exactly `dataSource` / `debug` / `debugFlags` / `apiFetch`,
4048+
// so the tail is unconditionally empty repo-wide (objectui#7206) —
4049+
// which is why the repair is this prop and could not have been a
4050+
// provider. A grid that supplied none of the three rendered every
4051+
// `dependsOn` column as a permanently gated, disabled trigger
4052+
// ("Select region first") even when the row carried the parent —
4053+
// a field that could never be filled, with no diagnostic. PR
4054+
// objectui#2216 gave the FORM renderer exactly this injection (its
4055+
// LIVE watched record); the other half — every picker taking the
4056+
// `dependsOn` chain as a hard `baseFilter` — is host-independent
4057+
// and was already live here, so this line supplies a missing INPUT
4058+
// and re-implements no cascade.
40394059
//
4040-
// The record a dependent widget scopes itself by. `LookupField`
4041-
// resolves `dependentValues ?? ctx.formValues ?? ctx.data ?? {}`
4042-
// and this grid supplied NONE of the three, so the resolved record
4043-
// was `{}` for every row. ⚠️ Only the FIRST link was ever
4044-
// suppliable, by this host or any other:
4045-
// `SchemaRendererContextType` declares exactly `dataSource` /
4046-
// `debug` / `debugFlags` / `apiFetch`, so the tail is
4047-
// unconditionally empty repo-wide (objectui#7206). That is why the
4048-
// repair has to be this prop and could not have been a provider. A column declaring `dependsOn` therefore
4049-
// rendered a permanently gated, disabled trigger ("Select region
4050-
// first") even when the row carried the parent value — a field
4051-
// that could never be filled, with no diagnostic. PR objectui#2216
4052-
// gave the FORM renderer exactly this injection (its live watched
4053-
// record); only that half was per-host, and the grid never got it.
4054-
// The other half — every picker taking the `dependsOn` chain as a
4055-
// hard `baseFilter` — is host-independent and was already live
4056-
// here, so this line supplies a missing INPUT and re-implements no
4057-
// cascade.
4058-
//
4059-
// ⛔ WHAT IS STILL WRONG, precisely: `ctx.row` is the PERSISTED
4060-
// record. A parent edited but NOT YET SAVED in this same row does
4061-
// not re-scope the child — the picker keeps listing candidates for
4062-
// the parent's saved value, and stays gated if that saved value is
4063-
// empty. objectui#2215's form fix was explicitly the LIVE record,
4064-
// so picking a parent re-scopes the child immediately. Matching
4065-
// that is objectui#7188, and it is the finished shape.
4066-
//
4067-
// Why the interim ships instead of the finished shape: the staged
4068-
// values live in `data-table`'s `pendingChanges` — in scope at the
4069-
// call site, so this is not a plumbing problem — and carrying them
4070-
// across needs a SEVENTH member on `renderCellEditor`'s context.
4071-
// `@object-ui/types` declares that context (objectui#6882,
4072-
// maintainer ruling 2026-08-30, replacing a `(schema as any)` cast)
4073-
// and pins its shape by EXACT type equality. That is a
4074-
// published-surface contract change with its own review floor, so
4075-
// it belongs to objectui#7188, not to this line.
4076-
//
4077-
// ⛔ Do NOT read this as settled. "Never fillable" → "scoped by
4078-
// the saved parent" is strictly better and strictly not finished;
4079-
// whether the user should be TOLD the scope came from the saved row
4080-
// is an OPEN question on objectui#7188, not a closed one.
4081-
dependentValues={ctx.row}
4060+
// `pendingRow` is the PERSISTED row shallow-merged with this row's
4061+
// STAGED, unsaved edits (`data-table` builds it from its
4062+
// `pendingChanges`), so a parent edited in the same row re-scopes
4063+
// the child before anything is saved — the form's semantics.
4064+
// `ctx.row` alone was #7165's interim and scoped by the SAVED
4065+
// parent. `pendingRow` is a REQUIRED member of the declared
4066+
// context (`@object-ui/types`, objectui#6882 + #7188), so the
4067+
// `?? ctx.row` never selects for a conforming host; it is spelled
4068+
// so a context handed to this factory WITHOUT it degrades to the
4069+
// saved-row scoping rather than to `{}` — gated forever — and it
4070+
// is the spelling objectui#7188's ruling chose.
4071+
dependentValues={ctx.pendingRow ?? ctx.row}
40824072
/>
40834073
);
40844074
}

0 commit comments

Comments
 (0)