diff --git a/.changeset/9547-ongrid-onnavigate-prop.md b/.changeset/9547-ongrid-onnavigate-prop.md new file mode 100644 index 0000000000..1e5b98461d --- /dev/null +++ b/.changeset/9547-ongrid-onnavigate-prop.md @@ -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. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 36d6bb5d6f..1095efaf2a 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -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'; @@ -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; @@ -1459,6 +1475,7 @@ export const ObjectGrid: React.FC = ({ onBulkDelete, onRowSelect, onRowClick, + onNavigate, onCellChange, onRowSave, onBatchSave, @@ -2830,8 +2847,12 @@ export const ObjectGrid: React.FC = ({ // 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 @@ -2840,7 +2861,7 @@ export const ObjectGrid: React.FC = ({ // 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, }); diff --git a/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx b/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx index 3162c936bc..6dea9f6fd3 100644 --- a/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx @@ -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]); diff --git a/packages/plugin-grid/src/__tests__/onNavigateProp-9547.test.tsx b/packages/plugin-grid/src/__tests__/onNavigateProp-9547.test.tsx new file mode 100644 index 0000000000..3082698ea8 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/onNavigateProp-9547.test.tsx @@ -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, + props: Partial = {}, +) { + 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( + + + , + ); +} + +/** Alice's email cell — a plain cell, so the click reaches the row handler. */ +async function aliceCell(container: HTMLElement): Promise { + 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; +type IsAny = 0 extends 1 & T ? true : false; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; +type ActionOf = F extends (recordId: string | number, action: infer A) => void ? A : never; +type ArityOf = F extends (...args: infer P) => void ? P['length'] : never; + +type PropFace = NonNullable; +type SchemaFace = NonNullable; +type ListViewFace = NonNullable; +type HookFace = NonNullable; + +/** 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>; +type _MembersAreExhaustive = Assert>; +type _NotAny = Assert, false>>; +type _PropFace = Assert, RecordNavigateAction>>; +type _SchemaFace = Assert, RecordNavigateAction>>; +type _ListViewFace = Assert, RecordNavigateAction>>; +type _HookFace = Assert, 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, 2>>; +type _SchemaArity = Assert, 2>>; +type _ListViewArity = Assert, 2>>; +type _HookArity = Assert, 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()); + }); +}); diff --git a/packages/react/src/hooks/useNavigationOverlay.ts b/packages/react/src/hooks/useNavigationOverlay.ts index a2f4c4167b..462d7cc471 100644 --- a/packages/react/src/hooks/useNavigationOverlay.ts +++ b/packages/react/src/hooks/useNavigationOverlay.ts @@ -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'; /** @@ -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. * diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index a1266ce9f6..a54b648d34 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -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 diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ccf3468268..57b8120148 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -473,6 +473,7 @@ export type { ObjectViewSchema, NamedListView, ViewNavigationConfig, + RecordNavigateAction, ViewTabBarConfig, ObjectQLComponentSchema, ObjectCalendarBlockConfig, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index cb016a8726..b791a82cfb 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1210,8 +1210,10 @@ export interface ObjectGridSchema extends BaseSchema { navigation?: ViewNavigationConfig; /** - * Callback for page-level navigation (used by 'page' mode). - * Called with recordId and action ('view' | 'edit'). + * 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. * * PROGRAMMATIC ONLY — not authoring surface. Deliberately absent from * `GRID_QUERY_INPUTS` (`@object-ui/plugin-grid`'s `index.tsx`), so the @@ -1225,10 +1227,11 @@ export interface ObjectGridSchema extends BaseSchema { * key. Writing it into a stored document does nothing at all. * * Programmatic callers should prefer `ObjectGridComponentProps` - * (`@object-ui/plugin-grid`), where the nine sibling callbacks — - * `onRowClick`, `onRowSelect`, `onCellChange`, `onRowSave`, `onBatchSave`, - * `onEdit`, `onDelete`, `onBulkDelete`, `onAddRecord` — live and only live, - * for exactly this reason. + * (`@object-ui/plugin-grid`), which declares ten callbacks: this one, as a + * prop since objectui#9547 — and the prop wins when both are supplied — and + * the nine siblings `onRowClick`, `onRowSelect`, `onCellChange`, + * `onRowSave`, `onBatchSave`, `onEdit`, `onDelete`, `onBulkDelete`, + * `onAddRecord`, which live and only live there, for exactly this reason. * * The declaration is kept rather than removed: with the key explicitly * published, removing it is a breaking public type change plus a deprecation @@ -1250,7 +1253,7 @@ export interface ObjectGridSchema extends BaseSchema { * from a schema and asserts the call. What is absent is a supplier, not the * channel, so `'retired'` ("no renderer reads this key") would be false. */ - onNavigate?: (recordId: string | number, action?: string) => void; + onNavigate?: (recordId: string | number, action: RecordNavigateAction) => void; /** * Conditional formatting rules for row/cell styling. @@ -2990,6 +2993,36 @@ export interface NamedListView { */ export type ViewNavigationConfig = NavigationConfig; +/** + * The second argument of the record-navigation `onNavigate` callback on the + * list channel: a navigation-MODE token from a CLOSED vocabulary. `'view'` + * opens the record page, `'new_window'` opens it in a new browser tab. + * + * One type, four faces (objectui#9547, maintainer ruling C of 2026-09-18): + * `ObjectGridSchema.onNavigate` and `ListViewRuntimeProps.onNavigate` here, + * `UseNavigationOverlayOptions.onNavigate` in `@object-ui/react` — the hook + * whose click handler is the one producer of this value — and + * `ObjectGridComponentProps.onNavigate` in `@object-ui/plugin-grid`. The + * hook's own calls are typed against this union, so emitting any other token + * is a compile error there rather than a branch a host silently misses. + * + * Written out here, not imported: `@objectstack/spec` (17.5.0 on) publishes + * this vocabulary only as TEXT — the `type` string of the `onNavigate` row in + * the react-tier `ListView` entry of `REACT_BLOCKS`, + * `(recordId, action: 'view' | 'new_window') => void` — and exports no + * TypeScript type to derive from. `plugin-grid`'s + * `__tests__/onNavigateProp-9547.test.tsx` reads that row and fails when the + * two vocabularies part. + * + * ⛔ Not `'edit'`: no branch of the hook emits it + * (objectstack#19057 dropped it from the catalogue). ⛔ Not `string`: the slot + * is closed, and an open type defers every misspelt mode to runtime. + * ⚠️ `ObjectViewSchema.onNavigate` is a different channel with its own + * vocabulary (`mode: 'view' | 'edit'`, invoked by `plugin-view`'s + * `ObjectView`) and does not use this type. + */ +export type RecordNavigateAction = 'view' | 'new_window'; + /** * ListView component node — DERIVED from the zod `ListViewSchema` (issue #2231), which * itself derives from `@objectstack/spec/ui` `ListViewSchema`. Spec-owned fields are @@ -3060,10 +3093,12 @@ type ListViewAuthored = { */ export interface ListViewRuntimeProps { /** - * Callback for page-level navigation (used by 'page' navigation mode). - * Called with recordId and action ('view' | 'edit'). + * 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. */ - onNavigate?: (recordId: string | number, action?: string) => void; + onNavigate?: (recordId: string | number, action: RecordNavigateAction) => void; /** * Callback fired when the user toggles row density/height via the toolbar. Lets the host diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 84a173c234..9fa3d5fe3c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -435,7 +435,8 @@ export const ObjectGridSchema = BaseSchema.extend({ // // ⛔ Disposition MEASURED, not patterned. `'retired'` publishes "no renderer // reads this key" — FALSE here: `ObjectGrid` reads it (`onNavigate: - // schema.onNavigate` into its `useNavigationOverlay` call) and + // onNavigate ?? schema.onNavigate` into its `useNavigationOverlay` call — + // the node key is the fallback behind the component prop, objectui#9547) and // `gridNonAuthorKeys.test.tsx` pins the read firing on a row click from a // SCHEMA-supplied function. So `'runtime-slot'`, and the channel is the one // the maintainer's 2026-08-19 ruling on objectui#5234 (option C) preserved on @@ -893,8 +894,9 @@ export const ObjectViewSchema = BaseSchema.extend({ // which is the TypeScript face, never `safeParse`. // // ⚠️ Same key NAME as `ObjectGridSchema.onNavigate` above, a DIFFERENT - // signature (`mode: 'view' | 'edit'` rather than the grid's `action?: string`) - // and a different supplier. Judged separately for that reason. + // signature (`mode: 'view' | 'edit'` rather than the grid's + // `action: RecordNavigateAction`, objectui#9547) and a different supplier. + // Judged separately for that reason. onNavigate: handlerKeyRefusal('onNavigate', 'runtime-slot', 'Record navigation handler'), // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER.