diff --git a/.changeset/10885-object-view-route2-named-grid.md b/.changeset/10885-object-view-route2-named-grid.md index ba1543e616..3c90008792 100644 --- a/.changeset/10885-object-view-route2-named-grid.md +++ b/.changeset/10885-object-view-route2-named-grid.md @@ -13,3 +13,5 @@ A named view's `hiddenFields` is applied the way the protocol composes it: the h What moves: only a grid view that authors one of these members. A named view that carries none of them renders exactly as before, and the host `renderListView` delegation is unchanged. `Clause-②: no` — no declared type, accepted key or published export moves. A renderer starts honouring members the spec already declares on a named view and the grid already reads. + +⚠️ **Dated note, 2026-09-28 — `navigation` has since been read off the named view — objectui#10885.** Later in this same release, the row click on this path follows the active named view's `navigation`, which replaces the node's `navigation` as a whole, and so do the surface and width of the forms it opens. The host `renderListView` delegation's row click follows it too, because both paths hand down the same click handler. A host `onRowClick` passed to `ObjectView` still wins on both paths. The same change applies a named view's `fieldOrder` to the projected columns, after `hiddenFields`, and hands the grid its `inlineEdit`, which `ObjectGrid` still gates on the object's inline-edit grant. `label` and `data` are not handed to the grid on this path, by ruling: `label` is already the tab's text, and `data` waits on objectui#10971. The rest of this entry is kept as the reading of this change; the objectui#10885 member 4 entry states what the path reads now. diff --git a/.changeset/10885-object-view-route2-named-view-rest.md b/.changeset/10885-object-view-route2-named-view-rest.md new file mode 100644 index 0000000000..1a1249e244 --- /dev/null +++ b/.changeset/10885-object-view-route2-named-view-rest.md @@ -0,0 +1,17 @@ +--- +'@object-ui/plugin-view': patch +--- + +A named view's `navigation`, `fieldOrder` and `inlineEdit` now reach the registered `object-view` renderer's grid (objectui#10885, member 4). + +`object-view` is registered without a `renderListView`, so an authored node, and the Studio's view preview, draws a grid named view as the `object-grid` node `ObjectView` builds for `ObjectGrid`. That path now reads three more members off the active named view: + +- `navigation` replaces the node's `navigation` as a whole while the named view is active. Everything `ObjectView` derives from it follows: the row click, the surface the record, create and edit forms open on, and that surface's width. Under a named `split` or `popover`, as under the node's own, the New button opens no form, because those two surfaces open only beside a selected record. The host `renderListView` delegation's row click follows it too: both paths hand down the same click handler, and until now that handler read the node's `navigation` only, so a named view's `navigation` was overruled there as well. A host `onRowClick` passed to `ObjectView` still wins on both paths. +- `fieldOrder` orders the projected columns, after `hiddenFields` has removed its fields, the same way `ListView` orders them on the delegation. Columns `fieldOrder` does not name keep their order after the named ones. A name the projection does not carry orders nothing, and with no projection nothing is added. +- `inlineEdit` is handed to the grid as `editable`. `ObjectGrid` still turns in-cell editing on only where the object grants inline edit and the user may update the record. + +`label` and `data`, also declared on both a named view and `object-grid`, are not handed to the grid on this path, by ruling. The named view's `label` is already the tab's text, and handing it to the grid would paint a second caption the delegation never paints. `data` is held back because `ListView` and `ObjectGrid` pick different objects when `data.object` differs from the node's `objectName` (objectui#10971). + +What moves: only a named view that authors one of these three members. The objectui#5097 host-composition relay is unchanged. + +`Clause-②: no` — no declared type, accepted key or published export moves. A renderer starts honouring members the spec already declares on a named view. diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index 616a8901b0..7538575a1c 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -352,7 +352,8 @@ const userDetail: ObjectViewSchema = { Without an `onNavigate` handler, `page` mode has nowhere to send the user, so keep the two together. `navigation: { mode: 'none' }` (or `preventNavigation`) -makes rows inert. +makes rows inert. An active named view (`listViews`, under Read/List) that +declares its own `navigation` replaces this one, as a whole, while it is shown. With no host `onRowClick`, a Cmd/Ctrl-click or middle-click on a row opens the record as a full page in a new browser tab. Inert rows stay inert: `mode: 'none'`, @@ -437,11 +438,25 @@ protocol declares under the same name on a named view and on `object-grid` — `pagination`, `selection`, `rowHeight`, `resizable`, `searchableFields`, `conditionalFormatting`, `rowActions`, `bulkActions`, `bulkActionDefs` and `exportOptions` — come from the active named view first; `pagination` and -`selection` still fall back to `table` (objectui#10885). `label`, `data` and -`navigation`, also declared on both, are not read off the named view on this -path. A named view's `hiddenFields` removes those fields from the columns the -grid draws, when a column list is declared. -`src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx` pins each member. +`selection` still fall back to `table` (objectui#10885). A named view's +`hiddenFields` removes those fields from the columns the grid draws, when a +column list is declared, and its `fieldOrder` then orders the columns that +remain, the way `ListView` orders them on a host's `renderListView`. Its +`inlineEdit` turns on in-cell editing, and only where the object grants inline +edit to the user. Its `navigation` replaces the node's `navigation` as a whole: +the row click, and the surface and width of the record, create and edit forms, +follow the active named view, on this path, and on a host's `renderListView` +when the host wires the `onRowClick` it is handed and passes `ObjectView` no +`onRowClick` of its own. `label` and `data`, also declared on both, are not +handed to the grid on this path: the named view's `label` is already the tab's +text, and `data` waits on objectui#10971, because `ListView` and `ObjectGrid` +pick different objects for it. +`src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx` pins the ten +grid members and `hiddenFields`; `ObjectView.namedViewNavigation-10885.test.tsx` +and `ObjectView.namedViewInlineEdit-10885.test.tsx` pin `navigation` and +`inlineEdit`, and `@object-ui/app-shell`'s +`objectViewRouteParity.fieldOrder-10885.test.tsx` pins `fieldOrder` against +`ListView`. **On a host's `renderListView`.** A host that composes `ObjectView` with both `listViews` and its own `renderListView` receives a `list-view` node for the diff --git a/packages/app-shell/src/__tests__/objectViewRouteParity.fieldOrder-10885.test.tsx b/packages/app-shell/src/__tests__/objectViewRouteParity.fieldOrder-10885.test.tsx new file mode 100644 index 0000000000..f570455719 --- /dev/null +++ b/packages/app-shell/src/__tests__/objectViewRouteParity.fieldOrder-10885.test.tsx @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10885 member 4 — route 2 orders a named view's columns by its + * `fieldOrder` exactly as the host delegation's `ListView` does. + * + * The protocol composes three members of a named list view (objectstack#15184 + * ruling B): `columns` projects, `hiddenFields` subtracts, `fieldOrder` orders + * what survives. `ObjectGrid` reads none of the last two, so each route applies + * them before the grid: + * + * - the host delegation relays all three to `ListView`, whose + * `effectiveFields` applies them and hands the grid the result; + * - route 2 (the registered `object-view` renderer, which the Studio's view + * preview uses) applies them to the projection it hands `ObjectGrid`. + * + * The claim is parity, so every case below mounts BOTH routes of the same + * `ObjectView` on the same named view and compares the two projections the + * grid receives. Nothing is compared with a hand-written order. + * + * This file lives in `@object-ui/app-shell` because it needs both + * `@object-ui/plugin-view` and `@object-ui/plugin-list`, and this is the + * package that depends on both. + * + * Sinks: route 2 renders the `ObjectGrid` export, replaced below; `ListView` + * renders the registry's `object-grid`, replaced for the file's duration. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ObjectView } from '@object-ui/plugin-view'; +import { ListView } from '@object-ui/plugin-list'; + +/** The node each route handed its grid, in mount order. */ +const routeTwoNodes: any[] = []; +const delegationNodes: any[] = []; + +vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectGrid: ({ schema }: any) => { + routeTwoNodes.push(schema); + return
; + }, +})); + +let previousGrid: any; +beforeAll(() => { + previousGrid = ComponentRegistry.get('object-grid'); + ComponentRegistry.register('object-grid', ({ schema }: any) => { + delegationNodes.push(schema); + return
; + }); +}); +afterAll(() => { + if (previousGrid) ComponentRegistry.register('object-grid', previousGrid); + else ComponentRegistry.unregister('object-grid'); +}); +afterEach(() => { + cleanup(); + routeTwoNodes.length = 0; + delegationNodes.length = 0; +}); + +function createDataSource(): any { + return { + find: vi.fn(async () => ({ data: [{ id: 't1', a: 'A', b: 'B', c: 'C', d: 'D' }], total: 1 })), + findOne: vi.fn(async () => null), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ + name: 'task', + label: 'Task', + fields: Object.fromEntries(['a', 'b', 'c', 'd'].map((f) => [f, { name: f, type: 'text', label: f.toUpperCase() }])), + })), + }; +} + +function objectView(view: Record, delegate: boolean) { + const ds = createDataSource(); + const renderListView = ({ schema, dataSource }: any) => ; + return ( + + + + ); +} + +/** The projection each route hands the grid for the same named view. */ +async function bothRoutes(view: Record): Promise<{ routeTwo: any; delegation: any }> { + render(objectView(view, false)); + await waitFor(() => expect(routeTwoNodes.length).toBeGreaterThan(0)); + const routeTwo = routeTwoNodes[routeTwoNodes.length - 1]; + cleanup(); + render(objectView(view, true)); + await waitFor(() => expect(delegationNodes.length).toBeGreaterThan(0)); + const delegation = delegationNodes[delegationNodes.length - 1]; + return { routeTwo, delegation }; +} + +const identity = (c: unknown) => (typeof c === 'string' ? c : (c as { field?: string }).field); + +describe('objectui#10885 — route 2 applies a named view\'s `fieldOrder` as `ListView` does', () => { + it('LIT CONTROL: with no `fieldOrder`, both routes hand the grid the same projection', async () => { + const { routeTwo, delegation } = await bothRoutes({ columns: ['a', 'b', 'c', 'd'] }); + expect(delegation.columns).toEqual(['a', 'b', 'c', 'd']); + expect(routeTwo.columns).toEqual(delegation.columns); + }); + + it('fields `fieldOrder` does not name sort after the named ones, in their projected order', async () => { + const { routeTwo, delegation } = await bothRoutes({ columns: ['a', 'b', 'c', 'd'], fieldOrder: ['c', 'a'] }); + // Non-vacuity: the delegation did reorder, so equality below is a reading. + expect(delegation.columns).not.toEqual(['a', 'b', 'c', 'd']); + expect(routeTwo.columns).toEqual(delegation.columns); + // Route 2's names slot carries the same order. + expect(routeTwo.fields).toEqual(routeTwo.columns.map(identity)); + }); + + it('a name the projection does not carry orders nothing', async () => { + const { routeTwo, delegation } = await bothRoutes({ columns: ['a', 'b', 'c'], fieldOrder: ['zz', 'c', 'yy'] }); + expect(delegation.columns).not.toEqual(['a', 'b', 'c']); + expect(routeTwo.columns).toEqual(delegation.columns); + }); + + it('`hiddenFields` subtracts first, then `fieldOrder` orders what survives', async () => { + const { routeTwo, delegation } = await bothRoutes({ columns: ['a', 'b', 'c', 'd'], hiddenFields: ['b'], fieldOrder: ['d', 'b', 'a'] }); + expect(delegation.columns).not.toContain('b'); + expect(delegation.columns[0]).toBe('d'); + expect(routeTwo.columns).toEqual(delegation.columns); + }); + + it('object-shaped columns keep their shape in the new order', async () => { + const { routeTwo, delegation } = await bothRoutes({ + columns: [{ field: 'a', width: 120 }, { field: 'b' }, { field: 'c' }], + fieldOrder: ['c', 'a'], + }); + expect(delegation.columns.map(identity)).not.toEqual(['a', 'b', 'c']); + expect(routeTwo.columns).toEqual(delegation.columns); + expect(routeTwo.fields).toEqual(routeTwo.columns.map(identity)); + }); + + it('with no projection, neither route invents one', async () => { + const { routeTwo, delegation } = await bothRoutes({ fieldOrder: ['c', 'a'] }); + expect(delegation.columns).toBeUndefined(); + expect(routeTwo.columns).toEqual(delegation.columns); + expect(routeTwo.fields).toBeUndefined(); + }); +}); diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index a50cd6317e..79e83e2370 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -418,7 +418,8 @@ const schema: ObjectViewSchema = { Without an `onNavigate` handler, `page` mode has nowhere to send the user, so keep the two together. `navigation: { mode: 'none' }` (or `preventNavigation`) -makes rows inert. +makes rows inert. An active named view (`listViews`, under Read/List) that +declares its own `navigation` replaces this one, as a whole, while it is shown. With no host `onRowClick`, a Cmd/Ctrl-click or middle-click on a row opens the record as a full page in a new browser tab. Inert rows stay inert: `mode: 'none'`, @@ -503,11 +504,25 @@ protocol declares under the same name on a named view and on `object-grid` — `pagination`, `selection`, `rowHeight`, `resizable`, `searchableFields`, `conditionalFormatting`, `rowActions`, `bulkActions`, `bulkActionDefs` and `exportOptions` — come from the active named view first; `pagination` and -`selection` still fall back to `table` (objectui#10885). `label`, `data` and -`navigation`, also declared on both, are not read off the named view on this -path. A named view's `hiddenFields` removes those fields from the columns the -grid draws, when a column list is declared. -`src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx` pins each member. +`selection` still fall back to `table` (objectui#10885). A named view's +`hiddenFields` removes those fields from the columns the grid draws, when a +column list is declared, and its `fieldOrder` then orders the columns that +remain, the way `ListView` orders them on a host's `renderListView`. Its +`inlineEdit` turns on in-cell editing, and only where the object grants inline +edit to the user. Its `navigation` replaces the node's `navigation` as a whole: +the row click, and the surface and width of the record, create and edit forms, +follow the active named view, on this path, and on a host's `renderListView` +when the host wires the `onRowClick` it is handed and passes `ObjectView` no +`onRowClick` of its own. `label` and `data`, also declared on both, are not +handed to the grid on this path: the named view's `label` is already the tab's +text, and `data` waits on objectui#10971, because `ListView` and `ObjectGrid` +pick different objects for it. +`src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx` pins the ten +grid members and `hiddenFields`; `ObjectView.namedViewNavigation-10885.test.tsx` +and `ObjectView.namedViewInlineEdit-10885.test.tsx` pin `navigation` and +`inlineEdit`, and `@object-ui/app-shell`'s +`objectViewRouteParity.fieldOrder-10885.test.tsx` pins `fieldOrder` against +`ListView`. **On a host's `renderListView`.** A host that composes `ObjectView` with both `listViews` and its own `renderListView` receives a `list-view` node for the diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 34f0656469..6eda338b1a 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -279,6 +279,26 @@ function withoutHiddenFields(columns: C | undefine }) as unknown as C; } +/** + * objectui#10885 — a named view's `fieldOrder`, the third step of the same + * composition: it orders what `withoutHiddenFields` left. `ObjectGrid` has no + * `fieldOrder` read and `object-grid` declares no such key, so the order is + * applied here, to both projection slots. + * + * The application is `ListView`'s own (its `effectiveFields`), step for step, + * so both routes hand `ObjectGrid` the same order for the same view: a stable + * sort by each entry's position in `fieldOrder`. An entry `fieldOrder` does not + * name, or one with no field identity, sorts after the named ones and keeps its + * place among them; a name the projection does not carry orders nothing; with + * no projection nothing is invented. + */ +function inFieldOrder(columns: C | undefined, order: readonly string[] | undefined): C | undefined { + if (!Array.isArray(columns) || !Array.isArray(order) || order.length === 0) return columns; + const rank = new Map(order.map((name, i) => [name, i])); + const at = (entry: unknown): number => rank.get(columnIdentity(entry) as string) ?? Infinity; + return [...(columns as readonly unknown[])].sort((a, b) => at(a) - at(b)) as unknown as C; +} + /** * objectui#10885 — a named view's `exportOptions`, in the one shape the * `object-grid` slot holds. @@ -1141,8 +1161,14 @@ export const ObjectView: React.FC = ({ return schema.defaultViewType || 'grid'; }, [currentNamedViewConfig, activeView, schema.defaultViewType]); - // Navigation config - const navigationConfig: ViewNavigationConfig | undefined = schema.navigation; + // Navigation config — objectui#10885: the active named view's `navigation` + // first, then the node's. A WHOLE-OBJECT replacement, like every other member + // read off a named view: nothing is merged key by key, and ⛔ there is no + // `activeView` rung. Every consumer follows it: `handleRowClick` (the + // `onRowClick` route 2 hands `ObjectGrid` and the delegation hands + // `renderListView`, which both obey it first), `formLayout` and the + // drawer / overlay `width`. + const navigationConfig: ViewNavigationConfig | undefined = currentNamedViewConfig?.navigation ?? schema.navigation; // Permissions context, read here rather than inside the fetch effect below: // an effect's DEPENDENCY ARRAY is evaluated during render, so `perms` has to @@ -2259,8 +2285,10 @@ export const ObjectView: React.FC = ({ // objectui#7928: see the non-grid fetch above. A retired string `sort` on a // named view reaches `ObjectGrid` unchanged, which refuses it out loud. const viewSort = (currentNamedViewConfig?.sort as ObjectGridSchema['sort']) || activeView?.sort; - // objectui#10885 — subtracted from whichever projection wins below. + // objectui#10885 — subtracted from whichever projection wins below, and + // what survives is put in the named view's `fieldOrder`. const hiddenFields = currentNamedViewConfig?.hiddenFields; + const fieldOrder = currentNamedViewConfig?.fieldOrder; return { type: 'object-grid', @@ -2274,15 +2302,16 @@ export const ObjectView: React.FC = ({ // takes the authored value raw. One value, two slots, each given the // shape it declares. // objectui#10885 — both slots lose the named view's `hiddenFields` - // (see `withoutHiddenFields`). - fields: withoutHiddenFields( + // (see `withoutHiddenFields`), then take its `fieldOrder` (see + // `inFieldOrder`). + fields: inFieldOrder(withoutHiddenFields( viewColumnFieldNames(currentNamedViewConfig?.columns) || activeView?.columns || schema.table?.fields, hiddenFields, - ), - columns: withoutHiddenFields( + ), fieldOrder), + columns: inFieldOrder(withoutHiddenFields( currentNamedViewConfig?.columns || activeView?.columns || schema.table?.columns, hiddenFields, - ), + ), fieldOrder), operations: { ...operations, create: false, // Create is handled by the view's create button @@ -2312,10 +2341,12 @@ export const ObjectView: React.FC = ({ // NAMED-VIEW SOURCED, like `grouping` / `rowColor` below: ⛔ no // `activeView` rung (the host `views` path never fed these slots on this // branch) and ⛔ no node read this branch did not already have. ⛔ No - // alias and no key `object-grid` does not declare: `hiddenFields` is - // applied to the projection above rather than relayed, and `navigation` - // is not relayed because this component passes `ObjectGrid` its own - // `onRowClick`, which the grid's navigation hook obeys first. + // alias and no key `object-grid` does not declare: `hiddenFields` and + // `fieldOrder` are applied to the projection above rather than relayed. + // `navigation` is still not relayed to `ObjectGrid`: this component + // passes the grid its own `onRowClick`, which the grid's navigation hook + // obeys first, and that handler reads the named view's `navigation` + // (`navigationConfig`, objectui#10885). pagination: currentNamedViewConfig?.pagination ?? schema.table?.pagination, selection: currentNamedViewConfig?.selection ?? schema.table?.selection, rowHeight: currentNamedViewConfig?.rowHeight, @@ -2335,6 +2366,12 @@ export const ObjectView: React.FC = ({ // delegation already hands `ObjectGrid` through `ListView`. bulkActionDefs: currentNamedViewConfig?.bulkActionDefs as ObjectGridSchema['bulkActionDefs'], exportOptions: gridExportOptions(currentNamedViewConfig?.exportOptions), + // objectui#10885 — the named view's `inlineEdit` is the grid's + // `editable`. Route 2 handed the grid no `editable` before, so there is + // no node rung. It cannot widen editing past a grant: `ObjectGrid` ANDs + // it with the object's inline-edit verdict and the principal's `update` + // grant (`inlineEditable`), as `ListView` does on the delegation. + editable: currentNamedViewConfig?.inlineEdit, // ⭐ objectui#8980 — the AUTHOR-REACHABLE read point for two of the // seventeen. `ObjectGrid` already reads both (`schema.grouping` in its // group-field memo and its reference collector, `useRowColor(schema.rowColor)`), diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewInlineEdit-10885.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewInlineEdit-10885.test.tsx new file mode 100644 index 0000000000..c6b2d679cb --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewInlineEdit-10885.test.tsx @@ -0,0 +1,129 @@ +/** + * 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#10885 member 4 — a named view's `inlineEdit` reaches the grid on + * route 2, and cannot widen editing past the object's grant. + * + * Route 2 (the registered `object-view` renderer, with no `renderListView`) + * hands `ObjectGrid` the node `ObjectView` builds. That node carried no + * `editable`, so a named view's `inlineEdit: true` was accepted and ignored: + * the Studio's view preview showed a stored inline-edit view read-only. The node + * now carries the named view's `inlineEdit` as `editable`. + * + * `ObjectGrid` gates the key itself: `inlineEditable` is `schema.editable` AND + * the object's inline-edit verdict (ADR-0103 bucket, `userActions.edit`, the + * server's effective operations) AND the principal's `update` grant. So these + * cases mount the REAL `ObjectGrid` and read the user-visible outcome: does a + * click on a cell open an in-cell editor. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; +import type { DataSource, ObjectViewSchema } from '@object-ui/types'; +import { ObjectView } from '../ObjectView'; +import { installExplainDouble } from './explainDouble'; + +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: () =>
, +})); + +const OBJECT = 'inline_edit_task'; + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as unknown as Element['scrollIntoView']; + } +}); + +beforeEach(() => { + installExplainDouble(); +}); + +afterEach(() => { + // Unmount before restoring the real `fetch` (objectui#7439). + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** A data source whose object schema carries `userActions` when given. */ +function makeDataSource(userActions?: Record): DataSource { + return { + find: vi.fn(async () => ({ data: [{ id: 'r1', name: 'Alpha' }], total: 1 })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ + name: OBJECT, + label: 'Task', + ...(userActions ? { userActions } : {}), + fields: { id: { type: 'text' }, name: { type: 'text', label: 'Name' } }, + })), + } as unknown as DataSource; +} + +/** + * Mount one grid named view on route 2, click its `Alpha` cell, and report + * whether an in-cell editor opened. A cell that is not editable lets the click + * through to the row, which opens the record form: that is read too, so a + * `false` is a reading of a click that landed, not of one that never did. + */ +async function clickCell(named: Record, userActions?: Record) { + const ds = makeDataSource(userActions); + const schema = { + type: 'object-view', + objectName: OBJECT, + layout: 'drawer', + defaultListView: 'v1', + listViews: { v1: { label: 'Open work', type: 'grid', columns: ['name'], ...named } }, + } as unknown as ObjectViewSchema; + render( + + + + + , + ); + await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument()); + const td = screen.getByText('Alpha').closest('td') as HTMLElement; + fireEvent.click(td); + // The editor mounts on click; one tick lets React flush it, so `false` means + // "never opened", not "not opened yet". + await new Promise((r) => setTimeout(r, 50)); + return { + editor: td.querySelector('input') != null, + recordForm: screen.queryByTestId('object-form') != null, + }; +} + +describe('objectui#10885 — route 2 hands `ObjectGrid` the named view\'s `inlineEdit` as `editable`', () => { + it('THE FIX: a named `inlineEdit: true` opens an in-cell editor when the object grants inline edit', async () => { + const r = await clickCell({ inlineEdit: true }); + expect(r.editor).toBe(true); + expect(r.recordForm).toBe(false); + }); + + it('THE GRANT STILL WINS: a named `inlineEdit: true` opens no editor when the object refuses inline edit', async () => { + const r = await clickCell({ inlineEdit: true }, { edit: false }); + expect(r.editor).toBe(false); + // The click landed: it went through to the row, as on a read-only grid. + expect(r.recordForm).toBe(true); + }); + + it('CONTROL: a named view that declares no `inlineEdit` stays read-only, as before', async () => { + const r = await clickCell({}); + expect(r.editor).toBe(false); + expect(r.recordForm).toBe(true); + }); +}); diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewNavigation-10885.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewNavigation-10885.test.tsx new file mode 100644 index 0000000000..451cf646ab --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewNavigation-10885.test.tsx @@ -0,0 +1,145 @@ +/** + * 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#10885 member 4 — a named view's `navigation` reaches the row click. + * + * `ObjectView` hands the SAME `handleRowClick` down both routes: route 2 passes + * it to `ObjectGrid` as `onRowClick`, and the host delegation passes it to + * `renderListView`. Both the grid's navigation hook and `ListView`'s obey that + * handler first, so what the handler reads is what a row click does. It read + * `navigationConfig`, and that was the node's `navigation` alone: a named view's + * `navigation` was relayed into the delegation's `list-view` node and then + * overruled by the handler, and route 2 did not read it at all. + * + * `navigationConfig` now reads the active named view first, as a WHOLE object + * (no key of the node's is merged in), then the node. Its other consumers, + * `formLayout` and the drawer width, follow it on the same read. + * + * `ObjectGrid` is replaced by a sink that keeps the `onRowClick` route 2 hands + * it; the delegation is a `renderListView` that keeps the one it is handed. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, cleanup, act } from '@testing-library/react'; +import { ObjectView } from '../ObjectView'; +import type { DataSource, ObjectViewSchema } from '@object-ui/types'; + +/** The `onRowClick` each mounted `ObjectGrid` received — route 2's handler. */ +const gridClicks: Array<(record: Record) => void> = []; + +vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectGrid: ({ onRowClick }: any) => { + gridClicks.push(onRowClick); + return
; + }, +})); +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: () =>
, +})); + +const dataSource = (): DataSource => ({ + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), +} as unknown as DataSource); + +const ROW = { id: 'r1', name: 'Alpha' }; + +beforeEach(() => { + cleanup(); + gridClicks.length = 0; +}); + +/** One grid named view, `v1`, carrying `navigation` when given. */ +function viewSchema(named: Record | undefined, node: Record): ObjectViewSchema { + return { + type: 'object-view', + objectName: 'task', + defaultListView: 'v1', + listViews: { v1: { label: 'Open work', type: 'grid', columns: ['name'], ...(named ? { navigation: named } : {}) } }, + ...node, + } as unknown as ObjectViewSchema; +} + +/** Route 2: mount, then click a row through the handler `ObjectGrid` was handed. */ +async function clickOnRouteTwo(schema: ObjectViewSchema) { + render(); + await waitFor(() => expect(gridClicks.length).toBeGreaterThan(0)); + act(() => gridClicks[gridClicks.length - 1](ROW)); +} + +/** The delegation: mount with a host `renderListView`, then click through the handler it was handed. */ +async function clickOnDelegation(schema: ObjectViewSchema) { + const hostClicks: Array<(record: Record) => void> = []; + const renderListView = vi.fn(({ onRowClick }: any) => { + hostClicks.push(onRowClick); + return
; + }); + render(); + await waitFor(() => expect(hostClicks.length).toBeGreaterThan(0)); + act(() => hostClicks[hostClicks.length - 1](ROW)); +} + +describe('objectui#10885 — the row click follows the active named view\'s `navigation` (route 2)', () => { + it('THE FIX: a named `page` navigation wins over the node\'s `drawer` — `onNavigate` is called', async () => { + const onNavigate = vi.fn(); + await clickOnRouteTwo(viewSchema({ mode: 'page' }, { navigation: { mode: 'drawer' }, onNavigate })); + expect(onNavigate).toHaveBeenCalledWith('r1', 'view'); + expect(screen.queryByTestId('object-form')).toBeNull(); + }); + + it('WHOLE OBJECT: no key of the node\'s `navigation` is merged into the named one', async () => { + // A per-key merge would carry the node's `preventNavigation` into the named + // `page` navigation, and the click would do nothing. + const onNavigate = vi.fn(); + await clickOnRouteTwo(viewSchema({ mode: 'page' }, { navigation: { mode: 'page', preventNavigation: true }, onNavigate })); + expect(onNavigate).toHaveBeenCalledWith('r1', 'view'); + }); + + it('CONTROL: a named view that declares no `navigation` leaves the node\'s in force', async () => { + const onNavigate = vi.fn(); + await clickOnRouteTwo(viewSchema(undefined, { navigation: { mode: 'page' }, onNavigate })); + expect(onNavigate).toHaveBeenCalledWith('r1', 'view'); + }); +}); + +describe('objectui#10885 — the host delegation\'s row click follows it too (one read serves both routes)', () => { + it('THE FIX: the handler `renderListView` is handed obeys the named `page` navigation', async () => { + const onNavigate = vi.fn(); + await clickOnDelegation(viewSchema({ mode: 'page' }, { navigation: { mode: 'drawer' }, onNavigate })); + expect(onNavigate).toHaveBeenCalledWith('r1', 'view'); + expect(screen.queryByTestId('object-form')).toBeNull(); + }); + + it('CONTROL: with no named `navigation`, the node\'s `drawer` opens the record form, as before', async () => { + const onNavigate = vi.fn(); + await clickOnDelegation(viewSchema(undefined, { navigation: { mode: 'drawer' }, onNavigate })); + expect(onNavigate).not.toHaveBeenCalled(); + expect(await screen.findByTestId('object-form')).toBeTruthy(); + }); +}); + +describe('objectui#10885 — the record overlay follows the named `navigation` (`formLayout` and the drawer width)', () => { + it('THE FIX: a named `drawer` navigation with a `width` opens the record in a drawer of that width, over the node\'s `modal` layout', async () => { + await clickOnRouteTwo(viewSchema({ mode: 'drawer', width: 640 }, { layout: 'modal' })); + const form = await screen.findByTestId('object-form'); + expect(form.closest('.max-w-\\[640px\\]'), 'the record form is not inside a container of the named width').not.toBeNull(); + }); + + it('CONTROL: with no named `navigation`, the record opens in the node\'s layout, with no named width', async () => { + await clickOnRouteTwo(viewSchema(undefined, { layout: 'modal' })); + const form = await screen.findByTestId('object-form'); + expect(form.closest('.max-w-\\[640px\\]')).toBeNull(); + }); +}); diff --git a/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx index 095c3af585..7068897e0c 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx @@ -23,7 +23,7 @@ * * A member is taken when the protocol declares it on a named view AND on * `object-grid`, under the same name, and `ObjectGrid` reads it. The first - * describe below derives both declarations from the installed protocol. Two + * describe below derives both declarations from the installed protocol. Three * bucket-① members are handled apart from that rule: * * - `hiddenFields` — declared on the named view, NOT on `object-grid`, and @@ -31,11 +31,16 @@ * subtracted from the column projection route 2 hands the grid. * - `navigation` — declared on both, but `ObjectView` hands `ObjectGrid` its own * `onRowClick`, and the grid's navigation hook obeys that first. Relayed, the - * member would be read by nothing on this route, so it is not relayed. + * member would be read by nothing on this route, so it is not relayed; + * since member 4 the handler route 2 passes reads it + * (`ObjectView.namedViewNavigation-10885.test.tsx`). + * - `inlineEdit` — `object-grid` declares it as `editable`; since member 4 + * route 2 hands it over under that name + * (`ObjectView.namedViewInlineEdit-10885.test.tsx`). * * The rest of bucket ① (`addRecord`, `allowPrinting`, `aria`, `compactToolbar`, - * `description`, `emptyState`, `filterableFields`, `inlineEdit`, `sharing`, - * `showRecordCount`, `userFilters`) is list chrome `ListView` draws. `object-grid` + * `description`, `emptyState`, `filterableFields`, `sharing`, `showRecordCount`, + * `userFilters`) is list chrome `ListView` draws. `object-grid` * does not declare it under the same name, so route 2 has no slot to read it * into. */ diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index c6490f22fb..85bb8539fa 100644 --- a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -162,7 +162,9 @@ const LEDGERED = 'listViews'; * live in the docblocks' prose only; the READ is the fact. */ const READ_TEXT: Record> = { - navigation: [[READER, 'const navigationConfig: ViewNavigationConfig | undefined = schema.navigation;']], + // objectui#10885: the node's `navigation` is still read, now as the fallback + // behind the active named view's. + navigation: [[READER, 'const navigationConfig: ViewNavigationConfig | undefined = currentNamedViewConfig?.navigation ?? schema.navigation;']], searchableFields: [[READER, 'searchableFields: currentNamedViewConfig?.searchableFields ?? activeView?.searchableFields ?? (schema as any).searchableFields,']], filterableFields: [[READER, 'filterableFields: currentNamedViewConfig?.filterableFields ?? activeView?.filterableFields ?? (schema as any).filterableFields,']], allowCreateView: [