From f73cab039341896a4a312e0f8c4420d9ddd20844 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 10:13:36 +0000 Subject: [PATCH 1/2] fix(plugin-grid): feed the inline cell editor the row as dependent values A `dependsOn` lookup column in an editable ObjectGrid was permanently uneditable. `LookupField` resolves `dependentValues ?? ctx.formValues ?? ctx.data ?? {}` and the grid's `renderCellEditor` supplied none of the three, so the resolved record was `{}` for every row, `dependenciesMissing` stayed `true`, and the trigger rendered disabled ("Select region first") even when the row carried the parent value. PR #2216 closed #2215 in two halves: the form renderer injects its live watched record, and every picker takes the `dependsOn` chain as a hard `baseFilter`. Half 2 is host-independent and was already live here; half 1 is per-host and the grid never got it. This supplies that missing input and re-implements no cascade. INTERIM: `ctx.row` is the saved record, so a parent edited but not yet saved in the same row does not re-scope the child. Carrying the staged record needs a seventh member on `renderCellEditor`'s published context type (declared in `@object-ui/types`, #6882, and pinned by exact type equality), which is a contract change tracked separately. The `dependsOn` case in `lookupPickerKeys-7154.test.tsx` pinned the gated behaviour and is UPDATED, not deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM --- packages/plugin-grid/src/ObjectGrid.tsx | 41 ++- .../gridDependentValues-7165.test.tsx | 290 ++++++++++++++++++ .../__tests__/lookupPickerKeys-7154.test.tsx | 70 +++-- 3 files changed, 377 insertions(+), 24 deletions(-) create mode 100644 packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 94798f011..e8ab332e4 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -3727,7 +3727,7 @@ export const ObjectGrid: React.FC = ({ // handing DataTable an editor factory would leave the built-in fallback // editors as the only reachable ones if any future path re-opened the mode. renderCellEditor: inlineEditable - ? (ctx: { column: any; value: any; stage: (v: any) => void; commit: (v?: any) => void }) => { + ? (ctx: { column: any; row: any; value: any; stage: (v: any) => void; commit: (v?: any) => void }) => { const fieldDef = (objectSchema as any)?.fields?.[ctx.column?.accessorKey]; if (!fieldDef || !hasFieldEditWidget(fieldDef.type)) return null; const discrete = DISCRETE_EDIT_TYPES.has(fieldDef.type); @@ -3747,6 +3747,45 @@ export const ObjectGrid: React.FC = ({ field={field} value={ctx.value} onChange={(v: any) => (discrete ? ctx.commit(v) : ctx.stage(v))} + // ⚠️ INTERIM (objectui#7165) — the SAVED row, not the staged one. + // + // The record a dependent widget scopes itself by. `LookupField` + // resolves `dependentValues ?? ctx.formValues ?? ctx.data ?? {}` + // and this grid supplied NONE of the three, so the resolved record + // was `{}` for every row. A column declaring `dependsOn` therefore + // rendered a permanently gated, disabled trigger ("Select region + // first") even when the row carried the parent value — a field + // that could never be filled, with no diagnostic. PR objectui#2216 + // gave the FORM renderer exactly this injection (its live watched + // record); only that half was per-host, and the grid never got it. + // The other half — every picker taking the `dependsOn` chain as a + // hard `baseFilter` — is host-independent and was already live + // here, so this line supplies a missing INPUT and re-implements no + // cascade. + // + // ⛔ WHAT IS STILL WRONG, precisely: `ctx.row` is the PERSISTED + // record. A parent edited but NOT YET SAVED in this same row does + // not re-scope the child — the picker keeps listing candidates for + // the parent's saved value, and stays gated if that saved value is + // empty. objectui#2215's form fix was explicitly the LIVE record, + // so picking a parent re-scopes the child immediately. Matching + // that is objectui#7188, and it is the finished shape. + // + // Why the interim ships instead of the finished shape: the staged + // values live in `data-table`'s `pendingChanges` — in scope at the + // call site, so this is not a plumbing problem — and carrying them + // across needs a SEVENTH member on `renderCellEditor`'s context. + // `@object-ui/types` declares that context (objectui#6882, + // maintainer ruling 2026-08-30, replacing a `(schema as any)` cast) + // and pins its shape by EXACT type equality. That is a + // published-surface contract change with its own review floor, so + // it belongs to objectui#7188, not to this line. + // + // ⛔ Do NOT read this as settled. "Never fillable" → "scoped by + // the saved parent" is strictly better and strictly not finished; + // whether the user should be TOLD the scope came from the saved row + // is an OPEN question on objectui#7188, not a closed one. + dependentValues={ctx.row} /> ); } diff --git a/packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx b/packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx new file mode 100644 index 000000000..db79b7843 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx @@ -0,0 +1,290 @@ +/** + * 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#7165 — the grid's inline editor SUPPLIES the dependent record, so a + * `dependsOn` lookup column is editable instead of gated forever. + * + * ## The defect this closes + * + * `LookupField` resolves `dependentValues ?? ctx.formValues ?? ctx.data ?? {}` + * and `ObjectGrid`'s `renderCellEditor` supplied NONE of the three: it rendered + * `FieldEditWidget` with `field` / `value` / `onChange` only, `SchemaRendererContext` + * has no `formValues`, and the grid sets no `ctx.data` for a row. The resolved + * record was therefore `{}` for EVERY row, `dependenciesMissing` was permanently + * `true`, and a column declaring `dependsOn` rendered a disabled trigger reading + * "Select region first" — even when the row carried the parent value. The field + * could never be filled and nothing said why. + * + * PR objectui#2216 closed objectui#2215 in two halves: the FORM renderer injects + * its live watched record as `dependentValues`, and every picker surface takes + * the `dependsOn` chain as a hard `baseFilter`. Half 2 is host-independent and + * was ALREADY live here — which is why the gate fired at all. Half 1 is + * per-host and the grid never got it. This card supplies that missing input; it + * re-implements no cascade, and `test 2` below is what proves that distinction + * rather than asserting it. + * + * ## ⚠️ INTERIM — this ships option A, and option A is not the conclusion + * + * `renderCellEditor` now passes `dependentValues={ctx.row}`, and `ctx.row` is + * the SAVED record. A parent edited but not yet saved in the same row does not + * re-scope the child. That is strictly better than a field that can never be + * filled and strictly not finished — the form's answer to objectui#2215 was the + * LIVE record. Carrying the staged record needs a seventh member on + * `renderCellEditor`'s context, which `@object-ui/types` declares (objectui#6882, + * maintainer ruling 2026-08-30) and pins by EXACT type equality — a + * published-surface contract change, filed as objectui#7188. + * + * ⭐ `test 4` pins that staleness AS CURRENT BEHAVIOUR, with its own proof that + * the staging actually happened (otherwise "still scoped by north" is true for + * the trivial reason that nothing was ever staged). objectui#7188 flips it, and + * it is the assertion that fails if someone later "simplifies" B back to A. + * + * ## Why every test carries a live control + * + * An enabled-side green is worthless if the control column is also broken. Each + * test renders the `dependsOn` column and a control column with the SAME + * reference and the SAME records in ONE render, differing only in the declared + * key — the shape objectui#6875 established and objectui#7154 reused. + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; + +registerAllFields(); + +const OBJECT = 'os_7165_task'; +const REF = 'os_7165_person'; + +/** Six north, six south — so "scoped" and "unscoped" are different lists. */ +const PEOPLE = Array.from({ length: 12 }, (_, i) => ({ + id: `p${i + 1}`, + name: `Person ${String(i + 1).padStart(2, '0')}`, + region: i < 6 ? 'north' : 'south', +})); + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = vi.fn() as any; + if (!(Element.prototype as any).hasPointerCapture) (Element.prototype as any).hasPointerCapture = () => false; + if (!(Element.prototype as any).setPointerCapture) (Element.prototype as any).setPointerCapture = () => {}; + if (!(Element.prototype as any).releasePointerCapture) (Element.prototype as any).releasePointerCapture = () => {}; +}); + +/** + * The referenced-object query honours the `$filter` record, so the dependent + * cascade is observable as RENDERED ROWS and not only as call arguments. + */ +function makeDataSource(rows: any[]) { + const refQueries: any[] = []; + return { + refQueries, + find: vi.fn(async (objectName: string, params: any) => { + if (objectName === REF) { + refQueries.push(params); + let recs = PEOPLE; + const filter = params?.$filter; + if (filter && typeof filter === 'object' && filter.region) { + recs = recs.filter((p) => p.region === filter.region); + } + const top = params?.$top ?? 50; + const skip = params?.$skip ?? 0; + return { data: recs.slice(skip, skip + top), total: recs.length, hasMore: false, pageSize: top }; + } + return { data: rows, total: rows.length, hasMore: false, pageSize: 50 }; + }), + findOne: vi.fn(async (objectName: string, id: string) => + objectName === REF ? (PEOPLE.find((p) => p.id === id) ?? null) : null, + ), + update: vi.fn(async (_o: string, _id: string, changes: any) => changes), + getObjectSchema: async (name: string) => { + if (name === REF) { + return { name, fields: { id: { type: 'text' }, name: { type: 'text' }, region: { type: 'text' } } }; + } + return { + name, + fields: { + id: { type: 'text' }, + title: { type: 'text', label: 'Title' }, + region: { type: 'text', label: 'Region' }, + owner: { type: 'lookup', label: 'Owner', reference: REF }, + regional_owner: { type: 'lookup', label: 'Regional owner', reference: REF, dependsOn: ['region'] }, + }, + }; + }, + } as any; +} + +/** `region` is EDITABLE here — test 4 stages into it. */ +const COLUMNS = [ + { field: 'title', label: 'Title', editable: false }, + { field: 'region', label: 'Region' }, + { field: 'owner', label: 'Owner', type: 'lookup' }, + { field: 'regional_owner', label: 'Regional owner', type: 'lookup' }, +]; + +function renderGrid(ds: any, rows: any[]) { + const schema: any = { + type: 'object-grid', + objectName: OBJECT, + editable: true, + singleClickEdit: true, + data: rows, + pagination: { pageSize: 50 }, + columns: COLUMNS, + }; + return render( + + + + + , + ); +} + +/** The n-th DATA cell of a row (`td[0]` is the row-number column). */ +function cellAt(container: HTMLElement, rowIndex: number, index: number): HTMLElement { + const rowEl = container.querySelectorAll('tbody tr')[rowIndex] as HTMLElement; + const tds = Array.from(rowEl.querySelectorAll('td')) as HTMLElement[]; + return tds[index + 1]; +} + +/** Single-click into a cell and hand back the widget's own trigger button. */ +async function openEditor(cell: HTMLElement): Promise { + fireEvent.click(cell); + return await waitFor(() => { + const btn = cell.querySelector('button'); + expect(btn).toBeTruthy(); + return btn as HTMLButtonElement; + }); +} + +const ROW_NORTH = { id: 't1', title: 'Task one', region: 'north', owner: null, regional_owner: null }; +const ROW_NO_REGION = { id: 't2', title: 'Task two', region: '', owner: null, regional_owner: null }; + +describe('objectui#7165 — the grid feeds the inline editor its row as dependent values', () => { + it('1 — the `dependsOn` column opens (it used to gate forever); the control opens too', async () => { + const rows = [ROW_NORTH]; + const ds = makeDataSource(rows); + const { container } = renderGrid(ds, rows); + await waitFor(() => expect(screen.getByText('Task one')).toBeInTheDocument()); + + // CONTROL — same reference, same records, no `dependsOn`. Load-bearing in + // BOTH directions: if this column were broken the test below would be + // measuring a dead picker path rather than the declared key. + const controlTrigger = await openEditor(cellAt(container, 0, 2)); + expect(controlTrigger.getAttribute('data-testid')).toBe('lookup-trigger-owner'); + expect(controlTrigger.disabled).toBe(false); + fireEvent.keyDown(document.body, { key: 'Escape' }); + + // ⭐ THE CARD'S MEASUREMENT, INVERTED. On `51449a043` and on `899730e0a` + // before this change, this trigger was `lookup-trigger-gated`, `disabled`, + // reading "Select region first" — with the row already carrying + // `region: 'north'`. It is now an ordinary named, enabled trigger. + const dependentTrigger = await openEditor(cellAt(container, 0, 3)); + expect(dependentTrigger.getAttribute('data-testid')).toBe('lookup-trigger-regional_owner'); + expect(dependentTrigger.disabled).toBe(false); + expect(dependentTrigger.textContent).not.toMatch(/select region first/i); + // The browse-all button shared the gate (PR objectui#2216) and is live too. + expect(within(cellAt(container, 0, 3)).getByTestId('browse-all-records')).not.toBeDisabled(); + }); + + it('2 — the picker is SCOPED by the row: north only, while the control offers south', async () => { + const rows = [ROW_NORTH]; + const ds = makeDataSource(rows); + const { container } = renderGrid(ds, rows); + await waitFor(() => expect(screen.getByText('Task one')).toBeInTheDocument()); + + // The declared column: `region: 'north'` reaches the query as a hard + // `$filter`, so only the six north people are candidates. This is what + // proves the fix supplied a CORRECT record and not merely a non-empty one + // — an unscoped picker would list Person 07. + fireEvent.click(await openEditor(cellAt(container, 0, 3))); + await waitFor(() => expect(screen.getByText('Person 01')).toBeInTheDocument()); + expect(screen.queryByText('Person 07')).not.toBeInTheDocument(); + expect(ds.refQueries.some((q: any) => q?.$filter?.region === 'north')).toBe(true); + fireEvent.keyDown(document.body, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByText('Person 01')).not.toBeInTheDocument()); + + // CONTROL — the sibling column declares no `dependsOn`, so the SAME + // reference over the SAME records is unfiltered and a south person is + // offered. Without this, "Person 07 is absent" could just mean the picker + // never loaded. + fireEvent.click(await openEditor(cellAt(container, 0, 2))); + await waitFor(() => expect(screen.getByText('Person 07')).toBeInTheDocument()); + }); + + it('3 — NEGATIVE CONTROL: an empty saved parent still gates, so the gate was not disabled', async () => { + // The fix supplies a record; it does not remove `dependenciesMissing`. A row + // whose parent is genuinely empty must still gate — otherwise the picker + // would issue an unfiltered query that ignores the cascade, which is the + // defect objectui#2215 filed in the first place. + const rows = [ROW_NORTH, ROW_NO_REGION]; + const ds = makeDataSource(rows); + const { container } = renderGrid(ds, rows); + await waitFor(() => expect(screen.getByText('Task two')).toBeInTheDocument()); + + const gatedTrigger = await openEditor(cellAt(container, 1, 3)); + expect(gatedTrigger.getAttribute('data-testid')).toBe('lookup-trigger-gated'); + expect(gatedTrigger.disabled).toBe(true); + expect(gatedTrigger.textContent).toMatch(/region/i); + fireEvent.keyDown(document.body, { key: 'Escape' }); + + // CONTROL — the row above, same render, same column: filled parent, open. + const openTrigger = await openEditor(cellAt(container, 0, 3)); + expect(openTrigger.getAttribute('data-testid')).toBe('lookup-trigger-regional_owner'); + expect(openTrigger.disabled).toBe(false); + }); + + it('4 — ⚠️ INTERIM (objectui#7188): a STAGED parent does NOT re-scope the child', async () => { + // ⛔ This pins what option A gets WRONG, as current behaviour. `ctx.row` is + // the SAVED record, so staging `region: 'south'` in this same row leaves the + // child scoped by the persisted `'north'`. objectui#7188 carries the staged + // record across the `renderCellEditor` seam and flips this test; until then + // the staleness is written down rather than left to be discovered. + const rows = [ROW_NORTH]; + const ds = makeDataSource(rows); + const { container } = renderGrid(ds, rows); + await waitFor(() => expect(screen.getByText('Task one')).toBeInTheDocument()); + + // Stage a new parent WITHOUT saving. `region` is a `text` field, so its + // widget is `TextField` and is NOT in `DISCRETE_EDIT_TYPES` — its `onChange` + // routes to `ctx.stage`, which writes `pendingChanges` without closing. + const regionCell = cellAt(container, 0, 1); + fireEvent.click(regionCell); + const regionInput = await waitFor(() => { + const el = regionCell.querySelector('input'); + expect(el).toBeTruthy(); + return el as HTMLInputElement; + }); + fireEvent.change(regionInput, { target: { value: 'south' } }); + + // Open the child. Clicking another cell moves the edit; the staged value + // stays in `pendingChanges`. + fireEvent.click(await openEditor(cellAt(container, 0, 3))); + await waitFor(() => expect(screen.getByText('Person 01')).toBeInTheDocument()); + + // ⭐ PROOF THE STAGING LANDED — without it this test passes for the trivial + // reason that nothing was ever staged. The region cell renders its PENDING + // value ('south') while the saved record still says 'north'. + await waitFor(() => { + expect(cellAt(container, 0, 1).textContent).toMatch(/south/); + }); + expect(rows[0].region).toBe('north'); + + // The interim's staleness: scoped by the SAVED 'north', not the staged + // 'south'. Person 01 is north (offered); Person 07 is south (not offered). + expect(screen.queryByText('Person 07')).not.toBeInTheDocument(); + expect(ds.refQueries.some((q: any) => q?.$filter?.region === 'north')).toBe(true); + expect(ds.refQueries.some((q: any) => q?.$filter?.region === 'south')).toBe(false); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx b/packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx index 229bbaf81..34b002fd3 100644 --- a/packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx +++ b/packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx @@ -54,24 +54,35 @@ * load-bearing in both directions: it proves the picker path is reached and * that the difference is the declared key rather than the fixture. * - * ## ⚠️ `dependsOn` arrives and GATES — objectui#2215's grid-side residue + * ## ⚠️ `dependsOn` arrives — it used to GATE FOREVER (objectui#7165) + * + * ⭐ THIS SECTION WAS REWRITTEN, AND THE CASE BELOW UPDATED RATHER THAN + * DELETED. When this file was written the `dependsOn` case pinned the DEFECT: + * the column rendered a permanently gated, disabled trigger. objectui#7165 + * fixed that, so the pin now states the fixed behaviour. It is updated in place + * on purpose — a deleted pin is indistinguishable from a pin that never + * existed, and this case is still the only place the four keys are compared + * against a live control in one render. * * objectui#2215 ("cascading lookup broken in forms; table picker bypasses the * dependent filter") was closed COMPLETED by PR objectui#2216, which fixed two * halves: the FORM renderer injects its live watched record as * `dependentValues`, and every picker surface takes the `dependsOn` chain as a - * hard `baseFilter`. Only the second half is host-independent. `LookupField` - * resolves `dependentValues ?? ctx.formValues ?? ctx.data ?? {}`, and the grid's - * inline editor supplies none of the three — `renderCellEditor`'s context object - * is `{ column, row, value, stage, commit, cancel }` and nothing forwards `row`. + * hard `baseFilter`. Only the second half is host-independent, and the grid + * never got the first: `LookupField` resolves + * `dependentValues ?? ctx.formValues ?? ctx.data ?? {}` and this grid's inline + * editor supplied none of the three, so the resolved record was `{}` for every + * row and the gate never lifted — a field that could never be filled. + * + * objectui#7165 supplies the missing input: `renderCellEditor` now passes + * `dependentValues={ctx.row}`. The cascade itself is unchanged (half 2 was + * always live here), which is why this case needs no new data source. * - * So a `dependsOn` lookup column in an editable grid renders a PERMANENTLY - * gated, disabled trigger ("Select region first") even when the row carries the - * parent value. That is the current behaviour, it is pinned below as such, and - * it is a separate defect from this card — filed rather than fixed here, - * because whether the grid should feed the SAVED row or the row's in-flight - * staged edits is a design question objectui#2215's form fix answered one way - * (live values) that the grid cannot copy without a staged-value channel. + * ⚠️ INTERIM — `ctx.row` is the SAVED record, so a parent edited but not yet + * saved in the same row does not re-scope the child. Carrying the staged record + * needs a seventh member on `renderCellEditor`'s published context type, which + * is objectui#7188. That staleness is pinned — deliberately, as the current + * behaviour — in `gridDependentValues-7165.test.tsx`, not here. */ import { describe, it, expect, vi, beforeAll } from 'vitest'; import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'; @@ -297,7 +308,7 @@ describe('objectui#7154 — the four picker keys reach the grid’s inline picke }); }); - it('`dependsOn` — the declared column gates; the control does not (objectui#2215’s grid-side residue)', async () => { + it('`dependsOn` — the declared column is USABLE and scoped; the control is unscoped (objectui#7165)', async () => { const rows = [{ id: 't1', title: 'Task one', region: 'north', owner: null, regional_owner: null }]; const ds = makeDataSource( { @@ -320,15 +331,28 @@ describe('objectui#7154 — the four picker keys reach the grid’s inline picke expect(plainTrigger.getAttribute('data-testid')).toBe('lookup-trigger-owner'); expect(plainTrigger.disabled).toBe(false); - // Declared `dependsOn: ['region']` — the key ARRIVES (the gate is proof it - // was read) and the picker is gated. The row carries `region: 'north'`, so - // the gate is not "the parent is empty": the grid feeds the widget no - // dependent values at all, which is why this state is permanent. - const gatedTrigger = await openEditor(cellAt(container, 3)); - expect(gatedTrigger.getAttribute('data-testid')).toBe('lookup-trigger-gated'); - expect(gatedTrigger.disabled).toBe(true); - expect(gatedTrigger.textContent).toMatch(/region/i); - // The browse-all button next to it is gated too (PR objectui#2216). - expect(within(cellAt(container, 3)).getByTestId('browse-all-records')).toBeDisabled(); + // Declared `dependsOn: ['region']`. ⭐ UPDATED BY objectui#7165 — this used + // to assert `lookup-trigger-gated` / `disabled === true`, which pinned the + // defect: the grid fed the widget NO dependent values, so the gate could + // never lift however the row was filled. The grid now passes + // `dependentValues={ctx.row}`, the row carries `region: 'north'`, so the + // dependency is satisfied and the picker is an ordinary usable trigger. + // + // The key still ARRIVES off the field def — which is this file's whole + // claim — and the proof is no longer the gate but the SCOPING asserted + // below: an unscoped picker would list all twelve people. + const dependentTrigger = await openEditor(cellAt(container, 3)); + expect(dependentTrigger.getAttribute('data-testid')).toBe('lookup-trigger-regional_owner'); + expect(dependentTrigger.disabled).toBe(false); + // The browse-all button next to it is live too (it shared the gate before). + expect(within(cellAt(container, 3)).getByTestId('browse-all-records')).not.toBeDisabled(); + + // The `dependsOn` chain reaches the query as a hard `$filter` (PR + // objectui#2216's half 2, which was always live here — it just never had + // an input). `region: 'north'` → only the six north people are offered. + fireEvent.click(dependentTrigger); + await waitFor(() => expect(screen.getByText('Person 01')).toBeInTheDocument()); + expect(screen.queryByText('Person 07')).not.toBeInTheDocument(); + expect(ds.refQueries.some((q: any) => q?.$filter?.region === 'north')).toBe(true); }); }); From f17b08047cc2298996cf84c75780316b85251cb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 10:17:30 +0000 Subject: [PATCH 2/2] chore(changeset): grid inline editor dependent values Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM --- .changeset/7165-grid-dependent-values.md | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .changeset/7165-grid-dependent-values.md diff --git a/.changeset/7165-grid-dependent-values.md b/.changeset/7165-grid-dependent-values.md new file mode 100644 index 000000000..f08912ac5 --- /dev/null +++ b/.changeset/7165-grid-dependent-values.md @@ -0,0 +1,30 @@ +--- +'@object-ui/plugin-grid': patch +--- + +Fix: a `dependsOn` lookup column is no longer permanently uneditable in an +editable `ObjectGrid`. + +`LookupField` resolves the record it gates on as +`dependentValues ?? ctx.formValues ?? ctx.data ?? {}`, and the grid's inline +cell editor supplied **none** of the three — `renderCellEditor` rendered +`FieldEditWidget` with `field` / `value` / `onChange` only, `SchemaRendererContext` +has no `formValues`, and the grid sets no `ctx.data` for a row. The resolved +record was therefore `{}` for every row, so a column declaring `dependsOn` +rendered a disabled trigger reading "Select region first" **even when the row +carried the parent value**. The field could never be filled and nothing said +why. + +PR #2216 closed #2215 in two halves: the form renderer injects its live watched +record as `dependentValues`, and every picker takes the `dependsOn` chain as a +hard `baseFilter`. The second half is host-independent and was already live on +the grid path — which is why the gate fired at all. The first half is per-host +and the grid never got it. `renderCellEditor` now passes +`dependentValues={ctx.row}`, supplying that missing input; no cascade is +re-implemented. + +⚠️ Interim, and deliberately labelled as such in the code (#7165): `ctx.row` is +the **saved** record, so a parent edited but not yet saved in the same row does +not re-scope the child — it stays scoped by the persisted value. Matching the +form's live-record semantics needs a new member on `renderCellEditor`'s +published context type and is tracked as #7188.