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
30 changes: 30 additions & 0 deletions .changeset/7165-grid-dependent-values.md
Original file line numberDiff line numberDiff line change
@@ -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.
41 changes: 40 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3727,7 +3727,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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);
Expand All@@ -3747,6 +3747,45 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
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}
/>
);
}
Expand Down
290 changes: 290 additions & 0 deletions packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(
<ActionProvider>
<SchemaRendererProvider dataSource={ds}>
<ObjectGrid schema={schema} dataSource={ds} />
</SchemaRendererProvider>
</ActionProvider>,
);
}

/** 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<HTMLButtonElement> {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
30 changes: 30 additions & 0 deletions .changeset/7165-grid-dependent-values.md
Original file line numberDiff line numberDiff line change
@@ -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.
41 changes: 40 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3727,7 +3727,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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);
Expand All@@ -3747,6 +3747,45 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
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}
/>
);
}
Expand Down
290 changes: 290 additions & 0 deletions packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(
<ActionProvider>
<SchemaRendererProvider dataSource={ds}>
<ObjectGrid schema={schema} dataSource={ds} />
</SchemaRendererProvider>
</ActionProvider>,
);
}

/** 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<HTMLButtonElement> {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
30 changes: 30 additions & 0 deletions .changeset/7165-grid-dependent-values.md
Original file line numberDiff line numberDiff line change
@@ -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.
41 changes: 40 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3727,7 +3727,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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);
Expand All@@ -3747,6 +3747,45 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
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}
/>
);
}
Expand Down
290 changes: 290 additions & 0 deletions packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(
<ActionProvider>
<SchemaRendererProvider dataSource={ds}>
<ObjectGrid schema={schema} dataSource={ds} />
</SchemaRendererProvider>
</ActionProvider>,
);
}

/** 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<HTMLButtonElement> {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
30 changes: 30 additions & 0 deletions .changeset/7165-grid-dependent-values.md
Original file line numberDiff line numberDiff line change
@@ -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.
41 changes: 40 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3727,7 +3727,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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);
Expand All@@ -3747,6 +3747,45 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
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}
/>
);
}
Expand Down
290 changes: 290 additions & 0 deletions packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(
<ActionProvider>
<SchemaRendererProvider dataSource={ds}>
<ObjectGrid schema={schema} dataSource={ds} />
</SchemaRendererProvider>
</ActionProvider>,
);
}

/** 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<HTMLButtonElement> {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
30 changes: 30 additions & 0 deletions .changeset/7165-grid-dependent-values.md
Original file line numberDiff line numberDiff line change
@@ -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.
41 changes: 40 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3727,7 +3727,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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);
Expand All@@ -3747,6 +3747,45 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
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}
/>
);
}
Expand Down
290 changes: 290 additions & 0 deletions packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(
<ActionProvider>
<SchemaRendererProvider dataSource={ds}>
<ObjectGrid schema={schema} dataSource={ds} />
</SchemaRendererProvider>
</ActionProvider>,
);
}

/** 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<HTMLButtonElement> {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
30 changes: 30 additions & 0 deletions .changeset/7165-grid-dependent-values.md
Original file line numberDiff line numberDiff line change
@@ -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.
41 changes: 40 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3727,7 +3727,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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);
Expand All@@ -3747,6 +3747,45 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
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}
/>
);
}
Expand Down
290 changes: 290 additions & 0 deletions packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(
<ActionProvider>
<SchemaRendererProvider dataSource={ds}>
<ObjectGrid schema={schema} dataSource={ds} />
</SchemaRendererProvider>
</ActionProvider>,
);
}

/** 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<HTMLButtonElement> {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
30 changes: 30 additions & 0 deletions .changeset/7165-grid-dependent-values.md
Original file line numberDiff line numberDiff line change
@@ -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.
41 changes: 40 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3727,7 +3727,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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);
Expand All@@ -3747,6 +3747,45 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
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}
/>
);
}
Expand Down
290 changes: 290 additions & 0 deletions packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(
<ActionProvider>
<SchemaRendererProvider dataSource={ds}>
<ObjectGrid schema={schema} dataSource={ds} />
</SchemaRendererProvider>
</ActionProvider>,
);
}

/** 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<HTMLButtonElement> {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
30 changes: 30 additions & 0 deletions .changeset/7165-grid-dependent-values.md
Original file line numberDiff line numberDiff line change
@@ -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.
41 changes: 40 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3727,7 +3727,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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);
Expand All@@ -3747,6 +3747,45 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
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}
/>
);
}
Expand Down
290 changes: 290 additions & 0 deletions packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(
<ActionProvider>
<SchemaRendererProvider dataSource={ds}>
<ObjectGrid schema={schema} dataSource={ds} />
</SchemaRendererProvider>
</ActionProvider>,
);
}

/** 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<HTMLButtonElement> {
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);
});
});
Loading
Loading