From ac5989279feec582e9610b558be7f8258cfb4043 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:35:59 +0000 Subject: [PATCH 1/3] feat(plugin-list): page list-view honours requiredPermissions and addRecord (objectui#13074) A page `list-view` node gates on `requiredPermissions` the way the record blocks do (hasCapabilities, the insufficient-permissions notice, no query), and its Add button opens the page's create path (RelatedRecordActionsValue.resolve(...).onCreate), withheld when the host offers none. Both keys are published; their by-measurement rows in the console parity gate are struck. `sharing` stays unpublished. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .changeset/13074-list-view-inert-keys.md | 10 + .../registry-inputs-spec-parity.test.ts | 17 +- packages/plugin-list/src/ListViewBlock.tsx | 98 +++++- .../ListViewBlock.inertKeys-13074.test.tsx | 284 ++++++++++++++++++ .../ListViewBlock.specInputs-13047.test.tsx | 32 +- packages/plugin-list/src/index.tsx | 32 +- 6 files changed, 429 insertions(+), 44 deletions(-) create mode 100644 .changeset/13074-list-view-inert-keys.md create mode 100644 packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx diff --git a/.changeset/13074-list-view-inert-keys.md b/.changeset/13074-list-view-inert-keys.md new file mode 100644 index 0000000000..a51f81ebc5 --- /dev/null +++ b/.changeset/13074-list-view-inert-keys.md @@ -0,0 +1,10 @@ +--- +'@object-ui/plugin-list': minor +--- + +A page `list-view` node now honours two keys of the spec's list view row, and the registration publishes both (objectui#13074): + +- `requiredPermissions`: the capabilities a viewer must all hold. A viewer missing one sees an insufficient-permissions notice in place of the list, the same notice the record blocks draw, and no records are queried. A provider that reports no capabilities still shows the list. +- `addRecord`: `enabled: true` draws the Add button, and on a page it now opens the object's create form, the one the object list opens, through the page's existing create path. A viewer who may not create records of the object, or a page with no host to open the form, gets no Add button instead of one that does nothing. `mode` and `formView` are not read. + +`sharing` stays unpublished: its Share button has no action the spec declares. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 48a537677f..4c9fca7899 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -1650,16 +1650,14 @@ const UNPUBLISHED_EXEMPTIONS: Record = { * header, a key the renderer does not honour, so publishing it would * advertise configuration the platform drops. Each reason names what the * renderer does instead, and each leaves this map the day a reader lands - * and the key is published. + * and the key is published. objectui#13074 struck two of the five: + * `requiredPermissions` and `addRecord`, which `ListViewBlock` now honours + * on a page node (`ListViewBlock.inertKeys-13074.test.tsx`). */ 'list-view.name': 'Unpublished by measurement (objectui#13047): read by nothing on a page `list-view` node. The node draws, queries and hands its view the same thing with and without it — the view\'s own `name` is its identity in metadata, which an embedded list has no use for.', - 'list-view.requiredPermissions': - 'Unpublished by measurement (objectui#13047): no audience gate on this block. A capability nobody holds still draws and queries the list, where the record blocks put an insufficient-permissions notice in place of the block. The server enforces the key on a served list view; nothing enforces it on an embedded one. A renderer gap the seat cards (objectui#13047\'s report).', 'list-view.sharing': 'Unpublished by measurement (objectui#13047): `sharing.type` draws a Share button with no click handler, and the sharing config is enforced nowhere in the renderer. Publishing it would advertise a dead control (objectui#13047\'s report).', - 'list-view.addRecord': - 'Unpublished by measurement (objectui#13047): `addRecord.enabled` draws an Add button that calls the host\'s `onAddRecord`, and a page node has no host to pass one, so the button does nothing there. A renderer gap the seat cards (objectui#13047\'s report).', 'list-view.filterableFields': 'Unpublished by measurement (objectui#13047): the row declares it as the legacy shorthand of `userFilters.fields` (end-user quick filters), and the list reads it as something else — the field list of the Filter panel — while it draws no quick-filter bar. Publishing it would advertise the row\'s meaning, which nothing honours; which meaning stands is a contract question for the seat (objectui#13047\'s report).', }; @@ -2837,6 +2835,7 @@ const actionLeafMemberPins = (type: 'action:button' | 'action:icon'): Record = { ...actionLeafMemberPins('action:button'), @@ -2931,6 +2930,10 @@ const MEMBER_PINS: Record = { // in one file (`LIST_VIEW_PIN_FILE`); `dataSource` in the binding's own file. // The six the objectstack `main` pin bump booked (`data`, `columns`, `filter`, // `sort`, `options`, `dataSource`) are among them. + 'list-view.addRecord': { + file: LIST_VIEW_13074_PIN_FILE, + pins: 'The `enabled` member, on a page node: under a host that publishes the page\'s create path (`RelatedRecordActionsValue.resolve`), clicking the Add button calls that object\'s `onCreate`, the record-form overlay the object list opens; a host `onAddRecord` prop outranks it; with no `onCreate` (no create grant, no host) or a greyed one the button is withheld rather than drawn dead; and a node with no `addRecord` draws no button and asks the host nothing. `position` is `resolveAddRecordPlacement`\'s own pin. `mode` and `formView` are not read, which the registration\'s description says (objectui#13074).', + }, 'list-view.appearance': { file: LIST_VIEW_PIN_FILE, pins: 'Two members, measured apart. `showDescription: false` hides the description the same node declares (the info control is gone). `allowedVisualizations` narrows the kinds a view SWITCHER offers, and a page node draws no switcher (`showViewSwitcher` is a host prop, not a key of the row), so a node whitelisting `[\'grid\']` still draws its authored kanban: the member is inert on a page node, which the registration\'s description says rather than hides (objectui#13047).', @@ -3027,6 +3030,10 @@ const MEMBER_PINS: Record = { file: LIST_VIEW_PIN_FILE, pins: 'The `pageSize` member caps the query (`$top`), and the same node with no `pagination` sends the default page (objectui#13047).', }, + 'list-view.requiredPermissions': { + file: LIST_VIEW_13074_PIN_FILE, + pins: 'Members are CAPABILITY names, ALL required, read through `hasCapabilities` under a real stock `MePermissionsProvider`: an unheld capability puts the insufficient-permissions notice in place of the list and no `find` is sent; a held one draws and queries the list; a PARTIAL grant on a two-entry array is gated (the `.every` row, with the full grant as its control); an empty array and an absent key are no gate; and a provider that reports no `systemPermissions` fails open (objectui#13074).', + }, 'list-view.rowActions': { file: LIST_VIEW_PIN_FILE, pins: 'Members are action NAMES (`of: \'string\'`): the list reaches the `object-grid` node `ListView` builds unchanged, and an absent key puts none there. The row menu the grid draws is `object-grid.rowActions`\'s pin (objectui#13047).', diff --git a/packages/plugin-list/src/ListViewBlock.tsx b/packages/plugin-list/src/ListViewBlock.tsx index 1447a67126..eba5fd8e3b 100644 --- a/packages/plugin-list/src/ListViewBlock.tsx +++ b/packages/plugin-list/src/ListViewBlock.tsx @@ -10,6 +10,7 @@ import React, { useContext } from 'react'; import { elementDataSourceBlock, isElementDataSourceConfig, isListViewVisualization } from '@object-ui/core'; import { useObjectTranslation } from '@object-ui/i18n'; import { usePageRegionPosture } from '@object-ui/components'; +import { usePermissions } from '@object-ui/permissions'; import { ElementDataSourceGate, SchemaRendererContext, @@ -177,6 +178,45 @@ function boundObjectName(schema: ListViewSchema): string | undefined { return undefined; } +/** + * The Add button of a page `list-view` node (objectui#13074), and the node + * `ListView` draws it from. + * + * `ListView` draws the button from `addRecord.enabled` and calls its host's + * `onAddRecord` prop, which a page node never receives. So on a page the click + * goes through the page's existing create path: the host's + * `RelatedRecordActionsValue.resolve` for the object this block lists, whose + * `onCreate` opens that object's create form (the console's record-form + * overlay, the form the object list's own New button opens). No host prop and + * no new export: the page already publishes that path, and the + * `record:related_list` block's "+ New" calls the same handler. + * + * - A host that hands this block `onAddRecord` itself keeps it: an explicit + * prop outranks the page path, as the explicit `dataSource` adapter does. + * - The host omits `onCreate` when the viewer may not create records of the + * object (or when no host routes to it: the designer, a standalone embed). + * The button is then withheld, with `addRecord` taken off the node, rather + * than drawn as a control that does nothing. So is a create the host greys + * (`createDisabled`), because `ListView` draws no greyed Add button. + * + * Plain function, not a hook: it is called inside the gate's render callback, + * on the node after the binding, so it reads the object the binding names. + */ +function pageAddRecord( + hostProp: (() => void) | undefined, + host: RelatedRecordActionsValue | null, + bound: ListViewSchema, +): { schema: ListViewSchema; onAddRecord: (() => void) | undefined } { + if (hostProp || bound.addRecord?.enabled !== true) return { schema: bound, onAddRecord: hostProp }; + const objectName = boundObjectName(bound); + const handlers = objectName && host ? host.resolve({ objectName }) : undefined; + if (handlers?.onCreate && handlers.createDisabled !== true) { + return { schema: bound, onAddRecord: handlers.onCreate }; + } + const { addRecord: _withheld, ...rest } = bound; + return { schema: rest, onAddRecord: undefined }; +} + /** * The "no object named" hint, in the gate's own words (the `view.noObject` * message), naming the page key this block reads: `data.object`. The gate's @@ -286,7 +326,8 @@ const ListViewBlock = elementDataSourceBlock(React.forwardRef 0 && !perms.hasCapabilities(required)) { + return ( +
+

+ Insufficient permissions to view list. +

+
+ ); + } + return ( - {(schema) => namesRecordSource(schema) - ? ( + {(schema) => { + if (!namesRecordSource(schema)) return ; + // objectui#13074 — the Add button opens the page's create path. + const add = pageAddRecord(props.onAddRecord, host, schema); + return ( - + - ) - : } + ); + }} ); })); diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx b/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx new file mode 100644 index 0000000000..6a5b27348a --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx @@ -0,0 +1,284 @@ +/** + * 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#13074 — two keys of the spec's `list-view` row that objectui#13047 + * measured inert on a page node, now honoured by `ListViewBlock` and published: + * + * - `requiredPermissions`: the audience gate, read the way the record blocks + * read it (`hasCapabilities` over the reported `systemPermissions`), with + * the same insufficient-permissions notice in place of the list and no + * query sent. + * - `addRecord`: the Add button opens the page's create path, the host's + * `RelatedRecordActionsValue.resolve(...).onCreate` for the listed object. + * + * The third key the card names, `sharing`, stays inert and unpublished: the + * spec declares no action for its Share button. Its control row is below. + * + * Every node is authored the way the spec authors it, in a `properties` bag + * the spec's own `list-view` row accepts, and rendered through the REAL + * `SchemaRenderer` and this package's `list-view` registration. Permissions + * come from a real stock `MePermissionsProvider`. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup, act, fireEvent, screen } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { + SchemaRenderer, + SchemaRendererProvider, + RelatedRecordActionsProvider, + type RelatedRecordActionsValue, + type RelatedRecordHandlers, +} from '@object-ui/react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; +// Registers the REAL `list-view`. +import '../index'; + +const OBJECT = 'crm_task'; + +type Row = Record; + +const ROWS: Row[] = [ + { id: 't-1', name: 'Draft the brief', stage: 'todo' }, + { id: 't-2', name: 'Ship the fold', stage: 'doing' }, +]; + +const OBJECT_DEF = { + name: OBJECT, + label: 'Task', + fields: { + id: { name: 'id', type: 'text', label: 'Id' }, + name: { name: 'name', type: 'text', label: 'Name' }, + stage: { name: 'stage', type: 'text', label: 'Stage' }, + }, +}; + +// The child grid is a stand-in, as in this package's other `list-view` tests: +// `plugin-grid` is not a dependency of this one. +ComponentRegistry.register( + 'object-grid', + () =>
, + { namespace: 'test', label: 'object-grid stand-in', category: 'view' }, +); + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + findOne: vi.fn(async () => null), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(async () => ROWS.length), + getObjectSchema: vi.fn(async () => OBJECT_DEF), + getObjects: vi.fn(async () => []), + onMutation: () => () => {}, + }; +} +type Adapter = ReturnType; + +/** A page `list-view` node whose `properties` the spec's own row accepts. */ +function node(properties: Row = {}): Row { + const bag = { data: { provider: 'object', object: OBJECT }, columns: ['name', 'stage'], ...properties }; + const parsed = (ComponentPropsMap as Record { success: boolean; error?: unknown } }>)[ + 'list-view' + ].safeParse(bag); + if (!parsed.success) { + throw new Error(`fixture refused by the spec's list-view row: ${JSON.stringify(parsed.error)}`); + } + return { type: 'list-view', properties: bag }; +} + +/** A `/me/permissions` payload; the object may be read, never created. */ +function me(systemPermissions: string[] | undefined): MePermissionsResponse { + return { + authenticated: true, + userId: 'u1', + tenantId: 't1', + roles: [], + permissionSets: [], + ...(systemPermissions === undefined ? {} : { systemPermissions }), + objects: { + [OBJECT]: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + }, + fields: {}, + } as MePermissionsResponse; +} + +interface MountOptions { + /** The viewer's capabilities; `undefined` = a provider that reports none; absent key = no provider. */ + held?: string[] | undefined; + /** The page host's create path, as the console's `RelatedRecordActionsBridge` publishes it. */ + host?: RelatedRecordActionsValue; +} + +function mount(properties: Row, options: MountOptions = {}) { + const dataSource = makeDataSource(); + let tree = ( + + + + ); + if (options.host) tree = {tree}; + if ('held' in options) tree = {tree}; + return { dataSource, ...render(tree) }; +} + +/** A host whose `resolve` answers `handlers` for any object, and records what it was asked. */ +function makeHost(handlers: RelatedRecordHandlers) { + const resolve = vi.fn(() => handlers); + return { resolve, host: { resolve } as RelatedRecordActionsValue }; +} + +async function listDrawn(dataSource: Adapter): Promise { + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + await screen.findByTestId('object-grid-standin'); +} + +/** Lets the gate, the object load and any query settle, so an absence is not an unfinished load. */ +async function settle(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); +} + +async function refused(dataSource: Adapter): Promise { + const notice = await screen.findByTestId('list-view-insufficient-permissions'); + expect(notice.getAttribute('role')).toBe('status'); + expect(notice.textContent).toBe('Insufficient permissions to view list.'); + await settle(); + expect(dataSource.find).not.toHaveBeenCalled(); + expect(screen.queryByTestId('object-grid-standin')).toBeNull(); +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('objectui#13074 — `requiredPermissions` gates a page `list-view` node the way the record blocks do', () => { + it('a viewer without the capability sees the insufficient-permissions notice, and no `find` is sent', async () => { + const { dataSource } = mount({ requiredPermissions: ['crm.manage'] }, { held: [] }); + await refused(dataSource); + }); + + it('a viewer holding the capability sees the list, queried', async () => { + const { dataSource } = mount({ requiredPermissions: ['crm.manage'] }, { held: ['crm.manage'] }); + await listDrawn(dataSource); + expect(screen.queryByTestId('list-view-insufficient-permissions')).toBeNull(); + }); + + it('ALL are required: a partial grant on a two-entry list is refused', async () => { + const { dataSource } = mount({ requiredPermissions: ['crm.manage', 'crm.export'] }, { held: ['crm.manage'] }); + await refused(dataSource); + }); + + it('CONTROL for the partial row: the full grant draws the list', async () => { + const { dataSource } = mount( + { requiredPermissions: ['crm.manage', 'crm.export'] }, + { held: ['crm.manage', 'crm.export'] }, + ); + await listDrawn(dataSource); + }); + + it('an empty list is no gate', async () => { + const { dataSource } = mount({ requiredPermissions: [] }, { held: [] }); + await listDrawn(dataSource); + }); + + it('CONTROL: a node without the key draws and queries for a viewer who holds nothing', async () => { + const { dataSource } = mount({}, { held: [] }); + await listDrawn(dataSource); + expect(screen.queryByTestId('list-view-insufficient-permissions')).toBeNull(); + }); + + it('a provider that reports no `systemPermissions` fails open (objectui#4656)', async () => { + const { dataSource } = mount({ requiredPermissions: ['crm.manage'] }, { held: undefined }); + await listDrawn(dataSource); + }); +}); + +describe('objectui#13074 — `addRecord` on a page `list-view` node opens the page\'s create path', () => { + it('clicking the Add button calls the host\'s `onCreate` for the listed object', async () => { + const onCreate = vi.fn(); + const { resolve, host } = makeHost({ onCreate }); + const { dataSource } = mount({ addRecord: { enabled: true } }, { host }); + await listDrawn(dataSource); + fireEvent.click(screen.getByTestId('add-record-button')); + expect(onCreate).toHaveBeenCalledTimes(1); + expect(resolve).toHaveBeenCalledWith({ objectName: OBJECT }); + expect(dataSource.create).not.toHaveBeenCalled(); + }); + + it('`position: \'both\'` draws two buttons, and each opens the create path', async () => { + const onCreate = vi.fn(); + const { host } = makeHost({ onCreate }); + const { dataSource } = mount({ addRecord: { enabled: true, position: 'both' } }, { host }); + await listDrawn(dataSource); + const buttons = screen.getAllByTestId('add-record-button'); + expect(buttons).toHaveLength(2); + for (const button of buttons) fireEvent.click(button); + expect(onCreate).toHaveBeenCalledTimes(2); + }); + + it('a viewer the host gives no `onCreate` (no create grant) gets no Add button, rather than a dead one', async () => { + const { host } = makeHost({ onView: vi.fn() }); + const { dataSource } = mount({ addRecord: { enabled: true } }, { host }); + await listDrawn(dataSource); + await settle(); + expect(screen.queryByTestId('add-record-button')).toBeNull(); + }); + + it('a create the host greys (`createDisabled`) is withheld too: the list draws no greyed Add button', async () => { + const onCreate = vi.fn(); + const { host } = makeHost({ onCreate, createDisabled: true }); + const { dataSource } = mount({ addRecord: { enabled: true } }, { host }); + await listDrawn(dataSource); + await settle(); + expect(screen.queryByTestId('add-record-button')).toBeNull(); + }); + + it('with no host at all (a designer preview, a standalone embed) the button is withheld', async () => { + const { dataSource } = mount({ addRecord: { enabled: true } }); + await listDrawn(dataSource); + await settle(); + expect(screen.queryByTestId('add-record-button')).toBeNull(); + }); + + it('CONTROL: a node without `addRecord` draws no Add button and asks the host nothing', async () => { + const onCreate = vi.fn(); + const { resolve, host } = makeHost({ onCreate }); + const { dataSource } = mount({}, { host }); + await listDrawn(dataSource); + await settle(); + expect(screen.queryByTestId('add-record-button')).toBeNull(); + expect(resolve).not.toHaveBeenCalled(); + }); +}); + +describe('objectui#13074 — `sharing` stays inert and unpublished (CONTROL)', () => { + it('the Share button still carries no click handler: a click changes nothing on the page', async () => { + const { dataSource } = mount({ sharing: { type: 'personal' } }); + await listDrawn(dataSource); + await settle(); + const share = screen.getByTestId('share-button'); + const before = document.body.innerHTML; + fireEvent.click(share); + await settle(); + expect(document.body.innerHTML).toBe(before); + }); + + it('the registration publishes `requiredPermissions` and `addRecord`, and not `sharing`', () => { + const inputs = (ComponentRegistry.getConfig('list-view')?.inputs ?? []).map((input: { name: string }) => input.name); + expect(inputs).toContain('requiredPermissions'); + expect(inputs).toContain('addRecord'); + expect(inputs).not.toContain('sharing'); + }); +}); diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx b/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx index b276e1a971..1192ba7ee7 100644 --- a/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx +++ b/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx @@ -35,12 +35,14 @@ * Every fixture is first parsed by the spec's own `list-view` row, so a key * measured here is measured in a shape the platform accepts. * - * The last describe block is the five keys measured NOT honoured on a page - * node, each held out of `inputs` with its reason in the console's repo-wide - * parity gate: `name` and `requiredPermissions` (read by nothing here), - * `sharing` and `addRecord` (each draws a button that does nothing on a page - * node), and `filterableFields` (read, but as the Filter panel's field list, - * not as the quick-filter shorthand the row declares it to be). One MEMBER is + * The last describe block is the keys measured NOT honoured on a page node, + * each held out of `inputs` with its reason in the console's repo-wide parity + * gate: `name` (read by nothing here), `sharing` (draws a button that does + * nothing on a page node), and `filterableFields` (read, but as the Filter + * panel's field list, not as the quick-filter shorthand the row declares it to + * be). `requiredPermissions` and `addRecord` were measured here too, until + * objectui#13074 honoured both on a page node; their pins now live in + * `ListViewBlock.inertKeys-13074.test.tsx`. One MEMBER is * recorded the same way beside the key that carries it: * `appearance.allowedVisualizations` narrows a view switcher a page node does * not draw, while `appearance.showDescription` acts. @@ -760,12 +762,6 @@ describe('objectui#13047 — measured NOT honoured on a page `list-view` node (h expect(container.textContent ?? '').not.toContain('open_tasks'); }); - it('`requiredPermissions`: a capability nobody holds still draws and queries the list', async () => { - const { dataSource } = mount({ requiredPermissions: ['capability_nobody_holds'] }); - await firstQuery(dataSource); - await handedTo('object-grid'); - }); - it('`sharing`: draws a Share button that carries no click handler — the sharing config is enforced nowhere here', async () => { const { dataSource } = mount({ sharing: { type: 'personal' } }); await loaded(dataSource); @@ -808,16 +804,4 @@ describe('objectui#13047 — measured NOT honoured on a page `list-view` node (h await loaded(dataSource); expect(await firstFilterField()).toBe('Amount'); }); - it('`addRecord.enabled`: draws an Add button that does nothing on a page node, which has no host `onAddRecord`', async () => { - const { dataSource } = mount({ addRecord: { enabled: true } }); - await loaded(dataSource); - const add = screen.getByTestId('add-record-button'); - const before = document.body.innerHTML; - fireEvent.click(add); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - }); - expect(document.body.innerHTML).toBe(before); - expect(dataSource.create).not.toHaveBeenCalled(); - }); }); diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index 37601fddce..56d42ce9f5 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -118,15 +118,15 @@ const LIST_VIEW_TYPE_INPUT: ComponentInput = { * objectui#13047). The member shapes are the spec's, named in each * description; the spec row is the judge of values. * - * Four keys of the row are NOT here, by the same measurement: `name` (read by - * nothing on a page node), `requiredPermissions` (no audience gate on this - * block), `sharing` (draws a Share button that does nothing) and `addRecord` - * (draws an Add button with no handler on a page node). So is - * `filterableFields`: the row declares it as the shorthand of - * `userFilters.fields`, and the list reads it as the Filter panel's field list - * instead. The console's repo-wide parity gate - * (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`) records - * each with its reason. + * Two keys of the row are NOT here, by the same measurement: `name` (read by + * nothing on a page node) and `sharing` (draws a Share button that does + * nothing: the spec declares no action for it). So is `filterableFields`: the + * row declares it as the shorthand of `userFilters.fields`, and the list reads + * it as the Filter panel's field list instead. The console's repo-wide parity + * gate (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`) + * records each with its reason. `requiredPermissions` and `addRecord` were + * among them until `ListViewBlock` honoured both on a page node + * (objectui#13074, `__tests__/ListViewBlock.inertKeys-13074.test.tsx`). * * `data` is NOT required (objectui#11605): the page compile reads this list, * so `required: true` would refuse a node bound by `dataSource.object`, which @@ -178,7 +178,21 @@ const LIST_VIEW_INPUTS: ComponentInput[] = [ description: 'One line about the list, drawn as the toolbar\'s info control (its tooltip and accessible name). A plain string, or an inline per-locale map resolved against the display locale. `appearance.showDescription: false` hides it.', }, + // ── the audience ────────────────────────────────────────────────────────── + { + name: 'requiredPermissions', + type: 'array', + of: 'string', + description: + 'Capabilities the viewer must ALL hold to see this list, the names permission sets grant through `systemPermissions`. A viewer missing one sees an insufficient-permissions notice in place of the list, and no records are queried. Absent or empty: no gate.', + }, // ── the toolbar ─────────────────────────────────────────────────────────── + { + name: 'addRecord', + type: 'object', + description: + 'The Add button, the spec\'s `AddRecordConfig`: `enabled: true` draws it, at `position` `top`, `bottom` or `both`. On a page it opens the object\'s create form, the one the object list opens; a viewer who may not create records of the object gets no button. `mode` and `formView` are not read: every click opens that create form.', + }, { name: 'userFilters', type: 'object', From e0c2d1f2775dc554a4dc673e465477cf35bc6a9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:54:18 +0000 Subject: [PATCH 2/3] docs(plugin-list): document requiredPermissions and addRecord on a page list-view node (objectui#13074) The package README and the plugin-list docs page describe the two keys a page list-view node now honours: the capability gate (the record blocks' notice, no query) and the Add button (the page's create path, withheld without a create grant or a host). `sharing` is stated to do nothing yet. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- content/docs/plugins/plugin-list.mdx | 28 +++++++++++++++++++ packages/plugin-list/README.md | 40 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/content/docs/plugins/plugin-list.mdx b/content/docs/plugins/plugin-list.mdx index 47087fa651..58c5677cd5 100644 --- a/content/docs/plugins/plugin-list.mdx +++ b/content/docs/plugins/plugin-list.mdx @@ -13,6 +13,34 @@ ListView plugin for Object UI - unified view component with view type switching pnpm add @object-ui/plugin-list ``` +## On a page — `requiredPermissions` and `addRecord` + +A page `list-view` node is written in `@objectstack/spec`'s list view spelling, +with its keys in `properties`. Two of them gate and extend what the node draws: + +```json +{ + "type": "list-view", + "properties": { + "data": { "provider": "object", "object": "deal" }, + "requiredPermissions": ["sales.view_pipeline"], + "addRecord": { "enabled": true } + } +} +``` + +- `requiredPermissions` — capabilities the viewer must all hold. A viewer + missing one sees "Insufficient permissions to view list." in place of the + list, as the record blocks show, and no records are queried. +- `addRecord` — `enabled: true` draws the Add button (`position`: `top`, + `bottom` or `both`). A click opens the listed object's create form through the + page's create path. A viewer who may not create records of the object, or a + page with no host to open the form, gets no button. `mode` and `formView` are + not read. + +`sharing` does nothing on a page node yet: its Share button has no action the +spec declares. + ## Status This plugin is part of the v3.3.0 release. For full API reference and usage diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index b6e8a4bf78..fca1f5073e 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -419,6 +419,46 @@ by name, on the node or in `properties`, each naming what to write instead list view draws is refused, and so is a list view prop such as `columns` written on the node itself rather than in `properties`. +## On a page — `requiredPermissions` and `addRecord` + +Two more keys of the spec's list view row act on a page `list-view` node. Both +sit in `properties`: + +```json +{ + "type": "list-view", + "properties": { + "data": { "provider": "object", "object": "deal" }, + "columns": ["name", "amount"], + "requiredPermissions": ["sales.view_pipeline"], + "addRecord": { "enabled": true, "position": "top" } + } +} +``` + +- **`requiredPermissions`** names the capabilities the viewer must **all** hold, + the names permission sets grant through `systemPermissions`. It gates the + node the way it gates the record blocks (`record:details`, + `record:highlights`, `record:related_list`). A viewer missing one sees the + notice "Insufficient permissions to view list." (`role="status"`) in place of + the list, and no records are queried. An empty list, or no key, is no gate. A + permission provider that reports no capabilities at all leaves the list shown; + the server stays the authority. Only the page node reads this key: the object + list page draws `ListView` itself, and the server gates the views it serves. +- **`addRecord`** draws the Add button: `enabled: true`, at `position` `top`, + `bottom` or `both`. On a page, a click opens the page's create path for the + listed object: the `onCreate` the host publishes through + `RelatedRecordActionsContext` (`@object-ui/react`). The console opens the + object's create form with it, the same form the object list's New button + opens. When the host offers no `onCreate` (the viewer may not create records + of the object, or there is no host, as in a designer preview or a standalone + embed), the button is not drawn; a create the host greys is not drawn either. + `mode` and `formView` are not read. A host that passes `ListView`'s own + `onAddRecord` prop keeps it. + +`sharing` does nothing on a page node yet: it draws a Share button with no +action, because the spec declares none for it. It is not a published input. + ## Page binding — `dataSource` (referencing a saved view by name) On a metadata page, a `list-view` component can also bind its data through the From 7156ba0657cc4497f7eb0dfd0279a15b7f6ca60b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:11:40 +0000 Subject: [PATCH 3/3] fix(plugin-list): unpublish list-view addRecord until a page host serves onCreate (objectui#13074) The console page host answers a parent-less list with no handlers, so a page list-view node's Add button is withheld there. addRecord leaves the registration inputs, its parity exemption returns citing objectui#13089, and its member pin is dropped. The block-level routing and its pins stay, retitled to a provider that serves onCreate; a restored row measures no Add button on a page node under no onCreate host. Docs and changeset keep requiredPermissions and drop every console create-form sentence. The schema handed to ListView is a named document-derived local again, so check-handler-key-read-sites follows the hop into ListView: the alias census is back to 11 rows from 7. Co-Authored-By: Claude --- .changeset/13074-list-view-inert-keys.md | 7 +-- .../registry-inputs-spec-parity.test.ts | 12 +++--- content/docs/plugins/plugin-list.mdx | 25 +++++------ packages/plugin-list/README.md | 43 ++++++++----------- packages/plugin-list/src/ListViewBlock.tsx | 37 +++++++++------- .../ListViewBlock.inertKeys-13074.test.tsx | 40 +++++++++-------- .../ListViewBlock.specInputs-13047.test.tsx | 39 ++++++++++++++--- packages/plugin-list/src/index.tsx | 27 ++++++------ 8 files changed, 124 insertions(+), 106 deletions(-) diff --git a/.changeset/13074-list-view-inert-keys.md b/.changeset/13074-list-view-inert-keys.md index a51f81ebc5..48647fd074 100644 --- a/.changeset/13074-list-view-inert-keys.md +++ b/.changeset/13074-list-view-inert-keys.md @@ -2,9 +2,6 @@ '@object-ui/plugin-list': minor --- -A page `list-view` node now honours two keys of the spec's list view row, and the registration publishes both (objectui#13074): +A page `list-view` node now honours `requiredPermissions`, a key of the spec's list view row, and the registration publishes it (objectui#13074): the capabilities a viewer must all hold. A viewer missing one sees an insufficient-permissions notice in place of the list, the same notice the record blocks draw, and no records are queried. A provider that reports no capabilities still shows the list. -- `requiredPermissions`: the capabilities a viewer must all hold. A viewer missing one sees an insufficient-permissions notice in place of the list, the same notice the record blocks draw, and no records are queried. A provider that reports no capabilities still shows the list. -- `addRecord`: `enabled: true` draws the Add button, and on a page it now opens the object's create form, the one the object list opens, through the page's existing create path. A viewer who may not create records of the object, or a page with no host to open the form, gets no Add button instead of one that does nothing. `mode` and `formView` are not read. - -`sharing` stays unpublished: its Share button has no action the spec declares. +`addRecord` and `sharing` stay unpublished. With `addRecord.enabled`, the Add button calls the `onCreate` a `RelatedRecordActionsContext` provider serves for the listed object, and is withheld, not drawn dead, where no provider serves one. `sharing`'s Share button has no action the spec declares. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 4c9fca7899..61d07e0f9f 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -1650,14 +1650,16 @@ const UNPUBLISHED_EXEMPTIONS: Record = { * header, a key the renderer does not honour, so publishing it would * advertise configuration the platform drops. Each reason names what the * renderer does instead, and each leaves this map the day a reader lands - * and the key is published. objectui#13074 struck two of the five: - * `requiredPermissions` and `addRecord`, which `ListViewBlock` now honours - * on a page node (`ListViewBlock.inertKeys-13074.test.tsx`). + * and the key is published. objectui#13074 struck one of the five: + * `requiredPermissions`, which `ListViewBlock` now honours on a page node + * (`ListViewBlock.inertKeys-13074.test.tsx`). */ 'list-view.name': 'Unpublished by measurement (objectui#13047): read by nothing on a page `list-view` node. The node draws, queries and hands its view the same thing with and without it — the view\'s own `name` is its identity in metadata, which an embedded list has no use for.', 'list-view.sharing': 'Unpublished by measurement (objectui#13047): `sharing.type` draws a Share button with no click handler, and the sharing config is enforced nowhere in the renderer. Publishing it would advertise a dead control (objectui#13047\'s report).', + 'list-view.addRecord': + 'Unpublished by measurement (objectui#13047, objectui#13074): on a page node the Add button is withheld, because the page host serves no `onCreate`. The block routes `addRecord.enabled` to the `onCreate` a `RelatedRecordActionsContext` provider serves for the listed object, and the console\'s page host answers a parent-less list with no handlers, so a console page draws no Add button (`ListViewBlock.specInputs-13047.test.tsx`). Serving `onCreate` there is objectui#13089\'s; the key is published when it lands.', 'list-view.filterableFields': 'Unpublished by measurement (objectui#13047): the row declares it as the legacy shorthand of `userFilters.fields` (end-user quick filters), and the list reads it as something else — the field list of the Filter panel — while it draws no quick-filter bar. Publishing it would advertise the row\'s meaning, which nothing honours; which meaning stands is a contract question for the seat (objectui#13047\'s report).', }; @@ -2930,10 +2932,6 @@ const MEMBER_PINS: Record = { // in one file (`LIST_VIEW_PIN_FILE`); `dataSource` in the binding's own file. // The six the objectstack `main` pin bump booked (`data`, `columns`, `filter`, // `sort`, `options`, `dataSource`) are among them. - 'list-view.addRecord': { - file: LIST_VIEW_13074_PIN_FILE, - pins: 'The `enabled` member, on a page node: under a host that publishes the page\'s create path (`RelatedRecordActionsValue.resolve`), clicking the Add button calls that object\'s `onCreate`, the record-form overlay the object list opens; a host `onAddRecord` prop outranks it; with no `onCreate` (no create grant, no host) or a greyed one the button is withheld rather than drawn dead; and a node with no `addRecord` draws no button and asks the host nothing. `position` is `resolveAddRecordPlacement`\'s own pin. `mode` and `formView` are not read, which the registration\'s description says (objectui#13074).', - }, 'list-view.appearance': { file: LIST_VIEW_PIN_FILE, pins: 'Two members, measured apart. `showDescription: false` hides the description the same node declares (the info control is gone). `allowedVisualizations` narrows the kinds a view SWITCHER offers, and a page node draws no switcher (`showViewSwitcher` is a host prop, not a key of the row), so a node whitelisting `[\'grid\']` still draws its authored kanban: the member is inert on a page node, which the registration\'s description says rather than hides (objectui#13047).', diff --git a/content/docs/plugins/plugin-list.mdx b/content/docs/plugins/plugin-list.mdx index 58c5677cd5..65e171e93f 100644 --- a/content/docs/plugins/plugin-list.mdx +++ b/content/docs/plugins/plugin-list.mdx @@ -13,33 +13,28 @@ ListView plugin for Object UI - unified view component with view type switching pnpm add @object-ui/plugin-list ``` -## On a page — `requiredPermissions` and `addRecord` +## On a page — `requiredPermissions` A page `list-view` node is written in `@objectstack/spec`'s list view spelling, -with its keys in `properties`. Two of them gate and extend what the node draws: +with its keys in `properties`. `requiredPermissions` gates what the node draws: ```json { "type": "list-view", "properties": { "data": { "provider": "object", "object": "deal" }, - "requiredPermissions": ["sales.view_pipeline"], - "addRecord": { "enabled": true } + "requiredPermissions": ["sales.view_pipeline"] } } ``` -- `requiredPermissions` — capabilities the viewer must all hold. A viewer - missing one sees "Insufficient permissions to view list." in place of the - list, as the record blocks show, and no records are queried. -- `addRecord` — `enabled: true` draws the Add button (`position`: `top`, - `bottom` or `both`). A click opens the listed object's create form through the - page's create path. A viewer who may not create records of the object, or a - page with no host to open the form, gets no button. `mode` and `formView` are - not read. - -`sharing` does nothing on a page node yet: its Share button has no action the -spec declares. +`requiredPermissions` lists capabilities the viewer must all hold. A viewer +missing one sees "Insufficient permissions to view list." in place of the list, +as the record blocks show, and no records are queried. + +`addRecord` and `sharing` are not published inputs of a page node yet: the Add +button is drawn only under a host that serves the object's create handler, and +the Share button has no action the spec declares. ## Status diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index fca1f5073e..8ba4cd2b3b 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -419,10 +419,10 @@ by name, on the node or in `properties`, each naming what to write instead list view draws is refused, and so is a list view prop such as `columns` written on the node itself rather than in `properties`. -## On a page — `requiredPermissions` and `addRecord` +## On a page — `requiredPermissions` -Two more keys of the spec's list view row act on a page `list-view` node. Both -sit in `properties`: +One more key of the spec's list view row acts on a page `list-view` node. It +sits in `properties`: ```json { @@ -430,31 +430,26 @@ sit in `properties`: "properties": { "data": { "provider": "object", "object": "deal" }, "columns": ["name", "amount"], - "requiredPermissions": ["sales.view_pipeline"], - "addRecord": { "enabled": true, "position": "top" } + "requiredPermissions": ["sales.view_pipeline"] } } ``` -- **`requiredPermissions`** names the capabilities the viewer must **all** hold, - the names permission sets grant through `systemPermissions`. It gates the - node the way it gates the record blocks (`record:details`, - `record:highlights`, `record:related_list`). A viewer missing one sees the - notice "Insufficient permissions to view list." (`role="status"`) in place of - the list, and no records are queried. An empty list, or no key, is no gate. A - permission provider that reports no capabilities at all leaves the list shown; - the server stays the authority. Only the page node reads this key: the object - list page draws `ListView` itself, and the server gates the views it serves. -- **`addRecord`** draws the Add button: `enabled: true`, at `position` `top`, - `bottom` or `both`. On a page, a click opens the page's create path for the - listed object: the `onCreate` the host publishes through - `RelatedRecordActionsContext` (`@object-ui/react`). The console opens the - object's create form with it, the same form the object list's New button - opens. When the host offers no `onCreate` (the viewer may not create records - of the object, or there is no host, as in a designer preview or a standalone - embed), the button is not drawn; a create the host greys is not drawn either. - `mode` and `formView` are not read. A host that passes `ListView`'s own - `onAddRecord` prop keeps it. +**`requiredPermissions`** names the capabilities the viewer must **all** hold, +the names permission sets grant through `systemPermissions`. It gates the node +the way it gates the record blocks (`record:details`, `record:highlights`, +`record:related_list`). A viewer missing one sees the notice "Insufficient +permissions to view list." (`role="status"`) in place of the list, and no +records are queried. An empty list, or no key, is no gate. A permission +provider that reports no capabilities at all leaves the list shown; the server +stays the authority. Only the page node reads this key: the object list page +draws `ListView` itself, and the server gates the views it serves. + +`addRecord` is not a published input. On a page node, `enabled: true` draws the +Add button only when a `RelatedRecordActionsContext` provider (`@object-ui/react`) +serves an `onCreate` for the listed object, and a click calls it; with no such +provider the button is not drawn, rather than drawn with no action. A host that +passes `ListView`'s own `onAddRecord` prop keeps it. `sharing` does nothing on a page node yet: it draws a Share button with no action, because the spec declares none for it. It is not a published input. diff --git a/packages/plugin-list/src/ListViewBlock.tsx b/packages/plugin-list/src/ListViewBlock.tsx index eba5fd8e3b..3eff70d41e 100644 --- a/packages/plugin-list/src/ListViewBlock.tsx +++ b/packages/plugin-list/src/ListViewBlock.tsx @@ -183,21 +183,22 @@ function boundObjectName(schema: ListViewSchema): string | undefined { * `ListView` draws it from. * * `ListView` draws the button from `addRecord.enabled` and calls its host's - * `onAddRecord` prop, which a page node never receives. So on a page the click - * goes through the page's existing create path: the host's - * `RelatedRecordActionsValue.resolve` for the object this block lists, whose - * `onCreate` opens that object's create form (the console's record-form - * overlay, the form the object list's own New button opens). No host prop and - * no new export: the page already publishes that path, and the - * `record:related_list` block's "+ New" calls the same handler. + * `onAddRecord` prop, which a page node never receives. So the click goes to + * the `onCreate` a `RelatedRecordActionsContext` provider serves for the + * object this block lists (`resolve({ objectName })`), the handler the + * `record:related_list` block's "+ New" calls. No host prop and no new export. + * The console's page host serves no `onCreate` for a parent-less list, so on a + * console page the button is withheld; serving it there is objectui#13089's, + * and `addRecord` stays unpublished until then. * * - A host that hands this block `onAddRecord` itself keeps it: an explicit - * prop outranks the page path, as the explicit `dataSource` adapter does. - * - The host omits `onCreate` when the viewer may not create records of the - * object (or when no host routes to it: the designer, a standalone embed). - * The button is then withheld, with `addRecord` taken off the node, rather - * than drawn as a control that does nothing. So is a create the host greys - * (`createDisabled`), because `ListView` draws no greyed Add button. + * prop outranks the provider, as the explicit `dataSource` adapter does. + * - A provider omits `onCreate` when the viewer may not create records of the + * object, and no provider serves one at all with no host (the designer, a + * standalone embed). The button is then withheld, with `addRecord` taken + * off the node, rather than drawn as a control that does nothing. So is a + * create the provider greys (`createDisabled`), because `ListView` draws no + * greyed Add button. * * Plain function, not a hook: it is called inside the gate's render callback, * on the node after the binding, so it reads the object the binding names. @@ -327,7 +328,7 @@ const ListViewBlock = elementDataSourceBlock(React.forwardRef {(schema) => { if (!namesRecordSource(schema)) return ; - // objectui#13074 — the Add button opens the page's create path. + // objectui#13074 — the Add button calls the provider's `onCreate`. const add = pageAddRecord(props.onAddRecord, host, schema); + // A named local, not `add.schema` inline: it is the same document (less a + // withheld `addRecord`), and `check-handler-key-read-sites` follows the + // hop into `ListView` only through a document-derived name. + const listSchema = add.schema; return ( handlers); return { resolve, host: { resolve } as RelatedRecordActionsValue }; @@ -205,8 +209,8 @@ describe('objectui#13074 — `requiredPermissions` gates a page `list-view` node }); }); -describe('objectui#13074 — `addRecord` on a page `list-view` node opens the page\'s create path', () => { - it('clicking the Add button calls the host\'s `onCreate` for the listed object', async () => { +describe('objectui#13074 — `addRecord` on a page `list-view` node calls the `onCreate` a provider serves', () => { + it('under a provider serving `onCreate`, clicking the Add button calls it for the listed object', async () => { const onCreate = vi.fn(); const { resolve, host } = makeHost({ onCreate }); const { dataSource } = mount({ addRecord: { enabled: true } }, { host }); @@ -217,7 +221,7 @@ describe('objectui#13074 — `addRecord` on a page `list-view` node opens the pa expect(dataSource.create).not.toHaveBeenCalled(); }); - it('`position: \'both\'` draws two buttons, and each opens the create path', async () => { + it('`position: \'both\'` under a provider serving `onCreate` draws two buttons, and each calls it', async () => { const onCreate = vi.fn(); const { host } = makeHost({ onCreate }); const { dataSource } = mount({ addRecord: { enabled: true, position: 'both' } }, { host }); @@ -228,7 +232,7 @@ describe('objectui#13074 — `addRecord` on a page `list-view` node opens the pa expect(onCreate).toHaveBeenCalledTimes(2); }); - it('a viewer the host gives no `onCreate` (no create grant) gets no Add button, rather than a dead one', async () => { + it('a provider that serves no `onCreate` for the object (no create grant) gets no Add button, rather than a dead one', async () => { const { host } = makeHost({ onView: vi.fn() }); const { dataSource } = mount({ addRecord: { enabled: true } }, { host }); await listDrawn(dataSource); @@ -236,7 +240,7 @@ describe('objectui#13074 — `addRecord` on a page `list-view` node opens the pa expect(screen.queryByTestId('add-record-button')).toBeNull(); }); - it('a create the host greys (`createDisabled`) is withheld too: the list draws no greyed Add button', async () => { + it('an `onCreate` the provider greys (`createDisabled`) is withheld too: the list draws no greyed Add button', async () => { const onCreate = vi.fn(); const { host } = makeHost({ onCreate, createDisabled: true }); const { dataSource } = mount({ addRecord: { enabled: true } }, { host }); @@ -245,14 +249,14 @@ describe('objectui#13074 — `addRecord` on a page `list-view` node opens the pa expect(screen.queryByTestId('add-record-button')).toBeNull(); }); - it('with no host at all (a designer preview, a standalone embed) the button is withheld', async () => { + it('with no provider at all (a designer preview, a standalone embed) the button is withheld', async () => { const { dataSource } = mount({ addRecord: { enabled: true } }); await listDrawn(dataSource); await settle(); expect(screen.queryByTestId('add-record-button')).toBeNull(); }); - it('CONTROL: a node without `addRecord` draws no Add button and asks the host nothing', async () => { + it('CONTROL: a node without `addRecord` draws no Add button and asks the provider nothing', async () => { const onCreate = vi.fn(); const { resolve, host } = makeHost({ onCreate }); const { dataSource } = mount({}, { host }); @@ -275,10 +279,10 @@ describe('objectui#13074 — `sharing` stays inert and unpublished (CONTROL)', ( expect(document.body.innerHTML).toBe(before); }); - it('the registration publishes `requiredPermissions` and `addRecord`, and not `sharing`', () => { + it('the registration publishes `requiredPermissions`, and neither `addRecord` (objectui#13089) nor `sharing`', () => { const inputs = (ComponentRegistry.getConfig('list-view')?.inputs ?? []).map((input: { name: string }) => input.name); expect(inputs).toContain('requiredPermissions'); - expect(inputs).toContain('addRecord'); + expect(inputs).not.toContain('addRecord'); expect(inputs).not.toContain('sharing'); }); }); diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx b/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx index 1192ba7ee7..82b2059be7 100644 --- a/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx +++ b/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx @@ -38,11 +38,13 @@ * The last describe block is the keys measured NOT honoured on a page node, * each held out of `inputs` with its reason in the console's repo-wide parity * gate: `name` (read by nothing here), `sharing` (draws a button that does - * nothing on a page node), and `filterableFields` (read, but as the Filter - * panel's field list, not as the quick-filter shorthand the row declares it to - * be). `requiredPermissions` and `addRecord` were measured here too, until - * objectui#13074 honoured both on a page node; their pins now live in - * `ListViewBlock.inertKeys-13074.test.tsx`. One MEMBER is + * nothing on a page node), `addRecord` (its Add button is withheld on a page + * node under no `onCreate` host, objectui#13089), and `filterableFields` + * (read, but as the Filter panel's field list, not as the quick-filter + * shorthand the row declares it to be). `requiredPermissions` was measured + * here too, until objectui#13074 honoured it on a page node; its pins now live + * in `ListViewBlock.inertKeys-13074.test.tsx`, beside the `addRecord` routing + * rows under a provider that serves `onCreate`. One MEMBER is * recorded the same way beside the key that carries it: * `appearance.allowedVisualizations` narrows a view switcher a page node does * not draw, while `appearance.showDescription` acts. @@ -57,7 +59,7 @@ import React from 'react'; import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, waitFor, cleanup, act, fireEvent, screen, within } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core'; -import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { SchemaRenderer, SchemaRendererProvider, RelatedRecordActionsProvider } from '@object-ui/react'; import { LocalizationProvider } from '@object-ui/i18n'; import { ComponentPropsMap } from '@objectstack/spec/ui'; // Registers the REAL `list-view` (and the `object-gallery` this file replaces below). @@ -804,4 +806,29 @@ describe('objectui#13047 — measured NOT honoured on a page `list-view` node (h await loaded(dataSource); expect(await firstFilterField()).toBe('Amount'); }); + + it('`addRecord.enabled`: no Add button on a page node under no `onCreate` host — withheld, not drawn dead (objectui#13089)', async () => { + // No provider at all. + const bare = mount({ addRecord: { enabled: true } }); + await loaded(bare.dataSource); + expect(screen.queryByTestId('add-record-button')).toBeNull(); + expect(bare.dataSource.create).not.toHaveBeenCalled(); + cleanup(); + + // A provider that, like the console's page host for a parent-less list, + // answers the object with no handlers at all. + const dataSource = makeDataSource(ROWS, {}); + const resolve = vi.fn(() => Object.freeze({})); + render( + + + + + , + ); + await loaded(dataSource); + expect(resolve).toHaveBeenCalledWith({ objectName: OBJECT }); + expect(screen.queryByTestId('add-record-button')).toBeNull(); + expect(dataSource.create).not.toHaveBeenCalled(); + }); }); diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index 56d42ce9f5..9566b2c61f 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -118,15 +118,18 @@ const LIST_VIEW_TYPE_INPUT: ComponentInput = { * objectui#13047). The member shapes are the spec's, named in each * description; the spec row is the judge of values. * - * Two keys of the row are NOT here, by the same measurement: `name` (read by - * nothing on a page node) and `sharing` (draws a Share button that does - * nothing: the spec declares no action for it). So is `filterableFields`: the - * row declares it as the shorthand of `userFilters.fields`, and the list reads - * it as the Filter panel's field list instead. The console's repo-wide parity - * gate (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`) - * records each with its reason. `requiredPermissions` and `addRecord` were - * among them until `ListViewBlock` honoured both on a page node - * (objectui#13074, `__tests__/ListViewBlock.inertKeys-13074.test.tsx`). + * Three keys of the row are NOT here, by the same measurement: `name` (read + * by nothing on a page node), `sharing` (draws a Share button that does + * nothing: the spec declares no action for it) and `addRecord` (the block + * routes its Add button to an `onCreate` its host serves, and the console page + * host serves none, so on a console page the button is withheld; objectui#13089 + * carries that host). So is `filterableFields`: the row declares it as the + * shorthand of `userFilters.fields`, and the list reads it as the Filter + * panel's field list instead. The console's repo-wide parity gate + * (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`) records + * each with its reason. `requiredPermissions` was among them until + * `ListViewBlock` honoured it on a page node (objectui#13074, + * `__tests__/ListViewBlock.inertKeys-13074.test.tsx`). * * `data` is NOT required (objectui#11605): the page compile reads this list, * so `required: true` would refuse a node bound by `dataSource.object`, which @@ -187,12 +190,6 @@ const LIST_VIEW_INPUTS: ComponentInput[] = [ 'Capabilities the viewer must ALL hold to see this list, the names permission sets grant through `systemPermissions`. A viewer missing one sees an insufficient-permissions notice in place of the list, and no records are queried. Absent or empty: no gate.', }, // ── the toolbar ─────────────────────────────────────────────────────────── - { - name: 'addRecord', - type: 'object', - description: - 'The Add button, the spec\'s `AddRecordConfig`: `enabled: true` draws it, at `position` `top`, `bottom` or `both`. On a page it opens the object\'s create form, the one the object list opens; a viewer who may not create records of the object gets no button. `mode` and `formView` are not read: every click opens that create form.', - }, { name: 'userFilters', type: 'object',