diff --git a/.changeset/13074-list-view-inert-keys.md b/.changeset/13074-list-view-inert-keys.md new file mode 100644 index 0000000000..48647fd074 --- /dev/null +++ b/.changeset/13074-list-view-inert-keys.md @@ -0,0 +1,7 @@ +--- +'@object-ui/plugin-list': minor +--- + +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. + +`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 48a537677f..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,16 +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. + * 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.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).', + '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).', }; @@ -2837,6 +2837,7 @@ const actionLeafMemberPins = (type: 'action:button' | 'action:icon'): Record = { ...actionLeafMemberPins('action:button'), @@ -3027,6 +3028,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/content/docs/plugins/plugin-list.mdx b/content/docs/plugins/plugin-list.mdx index 47087fa651..65e171e93f 100644 --- a/content/docs/plugins/plugin-list.mdx +++ b/content/docs/plugins/plugin-list.mdx @@ -13,6 +13,29 @@ ListView plugin for Object UI - unified view component with view type switching pnpm add @object-ui/plugin-list ``` +## On a page — `requiredPermissions` + +A page `list-view` node is written in `@objectstack/spec`'s list view spelling, +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"] + } +} +``` + +`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 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..8ba4cd2b3b 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -419,6 +419,41 @@ 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` + +One more key of the spec's list view row acts on a page `list-view` node. It +sits in `properties`: + +```json +{ + "type": "list-view", + "properties": { + "data": { "provider": "object", "object": "deal" }, + "columns": ["name", "amount"], + "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` 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. + ## Page binding — `dataSource` (referencing a saved view by name) On a metadata page, a `list-view` component can also bind its data through the diff --git a/packages/plugin-list/src/ListViewBlock.tsx b/packages/plugin-list/src/ListViewBlock.tsx index 1447a67126..3eff70d41e 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,46 @@ 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 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 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. + */ +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 +327,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 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 ( - + - ) - : } + ); + }} ); })); 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..7aad5e7114 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx @@ -0,0 +1,288 @@ +/** + * 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: + * + * - `requiredPermissions`, now honoured by `ListViewBlock` and published: 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`, routed but NOT published: the Add button calls the `onCreate` + * a `RelatedRecordActionsContext` provider serves for the listed object + * (`resolve({ objectName })`), and is withheld under a provider that serves + * none. The console's page host serves none for a parent-less list, so the + * key stays out of `inputs` until objectui#13089 lands that host. The rows + * below pin the block's contract against a provider, not a console page. + * + * 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; + /** A `RelatedRecordActionsContext` provider; absent = no provider at all. */ + 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 provider value 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 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 }); + 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\'` 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 }); + 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 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); + await settle(); + expect(screen.queryByTestId('add-record-button')).toBeNull(); + }); + + 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 }); + await listDrawn(dataSource); + await settle(); + expect(screen.queryByTestId('add-record-button')).toBeNull(); + }); + + 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 provider 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 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).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 b276e1a971..82b2059be7 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,16 @@ * 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), `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. @@ -55,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). @@ -760,12 +764,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 +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`: draws an Add button that does nothing on a page node, which has no host `onAddRecord`', async () => { - const { dataSource } = mount({ addRecord: { enabled: true } }); + + 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); - 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(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 37601fddce..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. * - * 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 + * 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. + * 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 @@ -178,6 +181,14 @@ 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: 'userFilters',