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
17 changes: 17 additions & 0 deletions .changeset/9547-ongrid-onnavigate-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/plugin-grid': minor
'@object-ui/types': minor
'@object-ui/react': minor
---

`ObjectGrid` takes `onNavigate` as a component prop, and the list channel's navigation callback takes one closed mode token, `'view' | 'new_window'`.

`ObjectGridSchema.onNavigate`'s documentation told programmatic callers to pass it on `ObjectGridComponentProps`, where the grid's nine other callbacks live — but that interface did not declare it, so a host that followed the advice passed a function the grid never called, with no diagnostic. `ObjectGridComponentProps` now declares `onNavigate`, and the grid reads the prop first and `schema.onNavigate` only when no prop is supplied: when both are given, the prop wins. `onRowClick`, when supplied, still takes the row click before either.

The callback's second argument is now `RecordNavigateAction`, a new type exported by `@object-ui/types`: the closed union `'view' | 'new_window'`, where `'view'` opens the record page and `'new_window'` opens it in a new browser tab. It is exactly what `useNavigationOverlay` emits, and the vocabulary `@objectstack/spec` 17.5.0 gives the react-tier `ListView` `onNavigate` callback. The same type now sits on all four faces of the channel — `ObjectGridComponentProps.onNavigate`, `ObjectGridSchema.onNavigate`, `ListViewSchema.onNavigate`, and `UseNavigationOverlayOptions.onNavigate` in `@object-ui/react` — where the three existing faces declared `action?: string` and two of them described it as `'view' | 'edit'`. No branch of the hook passes `'edit'`.

**Clause-②: yes** — one prop added (`ObjectGridComponentProps.onNavigate`), one type exported (`RecordNavigateAction`), and three published callback types narrowed from `(recordId, action?: string) => void` to `(recordId, action: RecordNavigateAction) => void`. What a host has to change:

- A handler you supply that is written `(id, action?: string) => …`, or with no second parameter at all, still compiles unchanged and receives exactly what it received before.
- A handler whose second parameter is annotated with a union that leaves out `'new_window'` — the old documentation's `'view' | 'edit'` is the likely one — is now refused with TS2322. Annotate it `RecordNavigateAction` (or drop the annotation) and handle `'new_window'`, which a Cmd/Ctrl/middle-click and `navigation.mode: 'new_window'` already deliver.
- Code that CALLS one of these callbacks itself must now pass the second argument, and it must be one of the two members.
29 changes: 25 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
*/

import React, { useEffect, useState, useCallback, useMemo } from 'react';
import type { ObjectGridSchema, DataSource, ListColumn, TableColumn, ViewData, TableSortItem, DataTableSchema, ListViewExportFormat } from '@object-ui/types';
import type { ObjectGridSchema, DataSource, ListColumn, TableColumn, ViewData, TableSortItem, DataTableSchema, ListViewExportFormat, RecordNavigateAction } from '@object-ui/types';
import { isSystemManagedField, normalizeTableColumnType } from '@object-ui/types';
import type { I18nLabel } from '@objectstack/spec/ui';
import { parseFilterAST, type FilterCondition } from '@objectstack/spec/data';
Expand Down Expand Up @@ -694,6 +694,22 @@ export interface ObjectGridComponentProps extends ObjectGridExternalPaginationPr
* already use this spelling for exactly this situation.
*/
onRowClick?: (record: any, event?: any) => void;
/**
* Called for page-level navigation with the record id and a
* {@link RecordNavigateAction} — a navigation-MODE token from a closed
* vocabulary: `'view'` opens the record page, `'new_window'` opens it in a
* new browser tab.
*
* [objectui#9547] The tenth callback on this interface, and the channel a
* programmatic caller should use: its nine siblings below are props here
* too, and `ObjectGridSchema.onNavigate`'s own docblock sends callers to
* this interface for it. That schema key still works — it is the
* same slot reached off the node — and when BOTH are supplied, THIS PROP
* WINS (maintainer ruling C on objectui#9547: the programmatic caller is the
* nearer party). `onRowClick` above still takes full priority over either:
* a host that owns the row click owns navigation too.
*/
onNavigate?: (recordId: string | number, action: RecordNavigateAction) => void;
onEdit?: (record: any) => void;
onDelete?: (record: any) => void;
onBulkDelete?: (records: any[]) => void;
Expand Down Expand Up @@ -1459,6 +1475,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
onBulkDelete,
onRowSelect,
onRowClick,
onNavigate,
onCellChange,
onRowSave,
onBatchSave,
Expand Down Expand Up @@ -2830,8 +2847,12 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// three. Programmatic callers already have the channel its nine siblings
// use: `onRowClick`, `onRowSelect`, `onCellChange`, `onRowSave`,
// `onBatchSave`, `onEdit`, `onDelete`, `onBulkDelete` and `onAddRecord`
// are props on `ObjectGridComponentProps`, and that is where a caller
// should prefer to pass this one too.
// are props on `ObjectGridComponentProps`, and since objectui#9547 this one
// is the tenth prop there, which is where a caller should prefer to pass
// it. When both are supplied the PROP WINS (maintainer ruling C on
// objectui#9547), so `schema.onNavigate` below is the fallback read — kept,
// because a host that builds the node in TypeScript still reaches the slot
// that way. `__tests__/onNavigateProp-9547.test.tsx` pins both halves.
//
// The contract says the same thing independently: `ComponentPropsMap
// ['object-grid']` (`@objectstack/spec@17`) is a `strictObject` and
Expand All @@ -2840,7 +2861,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// rejected there — are pinned by `__tests__/gridNonAuthorKeys.test.tsx`,
// together with the read itself, so this exemption cannot decay into a
// silent drop.
onNavigate: schema.onNavigate,
onNavigate: onNavigate ?? schema.onNavigate,
onRowClick,
});

Expand Down
9 changes: 6 additions & 3 deletions packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -784,11 +784,14 @@ describe('`onNavigate` — the exemption is DOCUMENTED, which is what this card
it('the read site in ObjectGrid.tsx carries the exemption comment', () => {
// Anchored on the read line, which exists in both states of the world, so a
// red here means the COMMENT went missing — never that the anchor moved.
const reads = indicesWhere(gridLines, (t) => t === 'onNavigate: schema.onNavigate,');
// Re-aimed by objectui#9547: the read became `onNavigate ?? schema.onNavigate`
// (the component prop first, the schema key as its fallback — ruling C on
// that card), and `onNavigateProp-9547.test.tsx` pins that precedence.
const reads = indicesWhere(gridLines, (t) => t === 'onNavigate: onNavigate ?? schema.onNavigate,');
expect(
reads.length,
'the `onNavigate: schema.onNavigate` read is gone or duplicated; re-aim this pin before'
+ ' trusting anything else in this describe.',
'the `onNavigate: onNavigate ?? schema.onNavigate` read is gone or duplicated; re-aim this'
+ ' pin before trusting anything else in this describe.',
).toBe(1);

const block = commentBlockAbove(gridLines, reads[0]);
Expand Down
208 changes: 208 additions & 0 deletions packages/plugin-grid/src/__tests__/onNavigateProp-9547.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* 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#9547 — `onNavigate` is a prop on `ObjectGridComponentProps`, the
* prop wins over `schema.onNavigate`, and the four faces of the list channel
* take one closed mode token.
*
* ## What was wrong
*
* `ObjectGridSchema.onNavigate`'s docblock sent programmatic callers to
* `ObjectGridComponentProps` — "that is where a caller should prefer to pass
* this one too" — and the interface did not declare it. A host that followed
* the advice passed a function the grid never called, with no diagnostic.
*
* ## What the maintainer ruled (ruling C on objectui#9547)
*
* The prop exists and is read as `props.onNavigate ?? schema.onNavigate`: the
* programmatic caller is the nearer party, so the prop wins. Its second
* argument is the closed union `'view' | 'new_window'` — the vocabulary
* `useNavigationOverlay` emits and the `@objectstack/spec` react-tier
* `ListView` catalogue publishes — and the three sibling faces
* (`ObjectGridSchema.onNavigate`, `ListViewRuntimeProps.onNavigate`,
* `UseNavigationOverlayOptions.onNavigate`) take the same type. ⛔ No `string`,
* ⛔ no `'edit'`: no branch of the hook emits it.
*
* ## The pins
*
* - behavioural: the prop fires in `page` mode with `'view'`; a modifier click
* delivers `'new_window'` to it; with BOTH supplied only the prop fires. The
* schema-only case is the control and already lives in
* `gridNonAuthorKeys.test.tsx` ("the renderer still reads it").
* - type-level: the four faces' second parameter IS `RecordNavigateAction`,
* required, and that type is exactly the two members. Compiled by this
* package's `tsconfig.test.json`; vitest erases them.
* - spec: the catalogue row's `type` STRING names the same members, so the
* one hand-spelled union in `@object-ui/types` cannot drift from it quietly.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { REACT_BLOCKS } from '@objectstack/spec/ui';
import { ActionProvider, type UseNavigationOverlayOptions } from '@object-ui/react';
import type { ListViewSchema, ObjectGridSchema, RecordNavigateAction } from '@object-ui/types';
import { ObjectGrid } from '../ObjectGrid';
import type { ObjectGridComponentProps } from '../ObjectGrid';

const rows = [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
];

function makeAdapter() {
return {
find: vi.fn().mockResolvedValue({ data: rows, total: rows.length }),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'test_object',
fields: { id: { type: 'text' }, name: { type: 'text' }, email: { type: 'text' } },
}),
};
}

function renderGrid(
schemaExtra: Record<string, unknown>,
props: Partial<ObjectGridComponentProps> = {},
) {
const schema = {
type: 'object-grid',
objectName: 'test_object',
columns: [{ field: 'name', label: 'Name' }, { field: 'email', label: 'Email' }],
data: { provider: 'value', items: rows },
navigation: { mode: 'page' },
...schemaExtra,
} as ObjectGridSchema;
return render(
<ActionProvider>
<ObjectGrid schema={schema} dataSource={makeAdapter() as any} {...props} />
</ActionProvider>,
);
}

/** Alice's email cell — a plain cell, so the click reaches the row handler. */
async function aliceCell(container: HTMLElement): Promise<HTMLElement> {
await waitFor(() => expect(screen.getByText('alice@example.com')).toBeInTheDocument());
const cell = Array.from(container.querySelectorAll('tbody td')).find(
(td) => td.textContent?.trim() === 'alice@example.com',
);
expect(cell, 'the fixture row never rendered, so a silent no-call would look like a pass').toBeTruthy();
return cell as HTMLElement;
}

describe('objectui#9547 — the `onNavigate` PROP reaches the row click', () => {
it('fires in `page` mode with the record id and `view`', async () => {
const onNavigate = vi.fn();
const { container } = renderGrid({ id: 'prop-page' }, { onNavigate });

fireEvent.click(await aliceCell(container));

await waitFor(() =>
expect(
onNavigate,
'the `onNavigate` PROP no longer fires on a row click — the read at the'
+ ' `useNavigationOverlay` call stopped consulting the prop.',
).toHaveBeenCalledTimes(1),
);
expect(onNavigate).toHaveBeenCalledWith('1', 'view');
});

it('a modifier click delivers the other member, `new_window`, to the same prop', async () => {
const onNavigate = vi.fn();
const { container } = renderGrid({ id: 'prop-meta' }, { onNavigate });

fireEvent.click(await aliceCell(container), { metaKey: true });

await waitFor(() => expect(onNavigate).toHaveBeenCalledTimes(1));
expect(onNavigate).toHaveBeenCalledWith('1', 'new_window');
});

it('with BOTH supplied, the prop wins and the schema key is not called', async () => {
const fromProp = vi.fn();
const fromSchema = vi.fn();
const { container } = renderGrid({ id: 'prop-wins', onNavigate: fromSchema }, { onNavigate: fromProp });

fireEvent.click(await aliceCell(container));

await waitFor(() => expect(fromProp).toHaveBeenCalledTimes(1));
expect(fromProp).toHaveBeenCalledWith('1', 'view');
expect(
fromSchema,
'`schema.onNavigate` fired although the prop was supplied — ruling C on objectui#9547'
+ ' made the prop win (`props.onNavigate ?? schema.onNavigate`).',
).not.toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
// One type, four faces — compile-time. The `it` below only keeps the suite
// honest about there being nothing to run; `tsc -p tsconfig.test.json` is what
// checks these.
// ---------------------------------------------------------------------------

type Assert<T extends true> = T;
type IsAny<T> = 0 extends 1 & T ? true : false;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;
type ActionOf<F> = F extends (recordId: string | number, action: infer A) => void ? A : never;
type ArityOf<F> = F extends (...args: infer P) => void ? P['length'] : never;

type PropFace = NonNullable<ObjectGridComponentProps['onNavigate']>;
type SchemaFace = NonNullable<ObjectGridSchema['onNavigate']>;
type ListViewFace = NonNullable<ListViewSchema['onNavigate']>;
type HookFace = NonNullable<UseNavigationOverlayOptions['onNavigate']>;

/** The two members, spelled once more so the spec comparison has a runtime value. */
const MEMBERS = ['new_window', 'view'] as const satisfies readonly RecordNavigateAction[];

type _TypeIsTheTwoMembers = Assert<Equal<RecordNavigateAction, 'view' | 'new_window'>>;
type _MembersAreExhaustive = Assert<Equal<(typeof MEMBERS)[number], RecordNavigateAction>>;
type _NotAny = Assert<Equal<IsAny<RecordNavigateAction>, false>>;
type _PropFace = Assert<Equal<ActionOf<PropFace>, RecordNavigateAction>>;
type _SchemaFace = Assert<Equal<ActionOf<SchemaFace>, RecordNavigateAction>>;
type _ListViewFace = Assert<Equal<ActionOf<ListViewFace>, RecordNavigateAction>>;
type _HookFace = Assert<Equal<ActionOf<HookFace>, RecordNavigateAction>>;
// Required on every face, as the catalogue types it: an optional `action?`
// would read `1 | 2` here and hand a host `undefined` to branch on.
type _PropArity = Assert<Equal<ArityOf<PropFace>, 2>>;
type _SchemaArity = Assert<Equal<ArityOf<SchemaFace>, 2>>;
type _ListViewArity = Assert<Equal<ArityOf<ListViewFace>, 2>>;
type _HookArity = Assert<Equal<ArityOf<HookFace>, 2>>;

describe('objectui#9547 — the four faces take one closed mode token', () => {
it('is pinned at compile time', () => {
expect(MEMBERS).toHaveLength(2);
});

it('the `@objectstack/spec` react-tier `ListView` catalogue row names exactly these members', () => {
const listView = REACT_BLOCKS.find((block) => block.tag === 'ListView');
expect(listView, 'the spec catalogue no longer carries a `ListView` block').toBeTruthy();
const row = listView!.interactions.find((prop) => prop.name === 'onNavigate');
expect(row, 'the spec `ListView` block no longer carries an `onNavigate` row').toBeTruthy();

// The catalogue types the callback as TEXT, e.g.
// "(recordId, action: 'view' | 'new_window') => void" — read the union out.
const union = /action\??:\s*([^)]*)\)/.exec(row!.type)?.[1];
expect(union, `the catalogue row's type no longer names an \`action\` parameter: ${row!.type}`).toBeTruthy();
const specMembers = union!
.split('|')
.map((member) => member.trim().replace(/^'|'$/g, ''))
.sort();

expect(
specMembers,
'`@objectstack/spec` now publishes a different navigation-mode vocabulary for'
+ ' `ListView.onNavigate` than `RecordNavigateAction` in `@object-ui/types`. Move the'
+ ' union there (and the four faces with it), or raise it upstream — never widen one side alone.',
).toEqual([...MEMBERS].sort());
});
});
16 changes: 14 additions & 2 deletions packages/react/src/hooks/useNavigationOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import { useState, useCallback, useMemo } from 'react';

import type { NavigationConfigSchema, NavigationMode as SpecNavigationMode } from '@objectstack/spec/ui';
import type { RecordNavigateAction } from '@object-ui/types';
import type { SpecAuthoredInput } from '../spec-input.js';

/**
Expand Down Expand Up @@ -171,8 +172,19 @@ export interface UseNavigationOverlayOptions {
navigation?: NavigationConfig;
/** Object name — used to build default URLs for page/new_window modes */
objectName?: string;
/** External onNavigate callback (e.g., from ActionProvider or parent) */
onNavigate?: (recordId: string | number, action?: string) => void;
/**
* Called for page-level navigation with the record id and a
* {@link RecordNavigateAction} — a navigation-MODE token from a closed
* vocabulary: `'view'` opens the record page, `'new_window'` opens it in a
* new browser tab.
*
* `handleClick` below is the one producer of that token, and every call it
* makes is typed against this declaration — so a third token is a compile
* error here, not a branch a host's handler silently never matches
* (objectui#9547). Supplied by the host that renders the list (a
* `schema.onNavigate`, a component prop, or the host's own router closure).
*/
onNavigate?: (recordId: string | number, action: RecordNavigateAction) => void;
/**
* External onRowClick callback — if set, takes full priority.
*
Expand Down
11 changes: 6 additions & 5 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2334,17 +2334,18 @@ interface KnownDrift {
* `ComponentPropsMap['object-grid']` `strictObject` already refused the key.
*
* ⚠️ No in-repo host builds an `object-grid` node carrying it — measured. The
* read is live and deliberate (`onNavigate: schema.onNavigate` into
* `useNavigationOverlay`), and `plugin-grid`'s `gridNonAuthorKeys.test.tsx`
* read is live and deliberate (`onNavigate: onNavigate ?? schema.onNavigate`
* into `useNavigationOverlay` — the fallback behind the component prop since
* objectui#9547), and `plugin-grid`'s `gridNonAuthorKeys.test.tsx`
* supplies it from a schema and asserts the call fires.
*/
'objectql.zod.ts#ObjectGridSchema': 'onNavigate';
/**
* RUNTIME SLOT (objectui#6124 shape, declared by objectui#7804) — the same
* key NAME as the entry above and nothing else in common: a different second
* parameter (`mode: 'view' | 'edit'` rather than `action?: string`), a
* different renderer, a different supplier. Judged separately for exactly
* that reason.
* parameter (`mode: 'view' | 'edit'` rather than
* `action: RecordNavigateAction`, objectui#9547), a different renderer, a
* different supplier. Judged separately for exactly that reason.
*
* `plugin-view`'s `ObjectView` invokes it at four sites —
* `schema.onNavigate('new', 'edit')` on create, and the record id with
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ export type {
ObjectViewSchema,
NamedListView,
ViewNavigationConfig,
RecordNavigateAction,
ViewTabBarConfig,
ObjectQLComponentSchema,
ObjectCalendarBlockConfig,
Expand Down
Loading
Loading