Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/13074-list-view-inert-keys.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 9 additions & 4 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1650,16 +1650,16 @@ const UNPUBLISHED_EXEMPTIONS: Record<string, string> = {
* 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).',
};
Expand Down Expand Up @@ -2837,6 +2837,7 @@ const actionLeafMemberPins = (type: 'action:button' | 'action:icon'): Record<str
* pins the members of every structured one but `dataSource`.
*/
const LIST_VIEW_PIN_FILE = 'packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx';
const LIST_VIEW_13074_PIN_FILE = 'packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx';

const MEMBER_PINS: Record<string, MemberPin> = {
...actionLeafMemberPins('action:button'),
Expand Down Expand Up @@ -3027,6 +3028,10 @@ const MEMBER_PINS: Record<string, MemberPin> = {
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).',
Expand Down
23 changes: 23 additions & 0 deletions content/docs/plugins/plugin-list.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions packages/plugin-list/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 97 additions & 6 deletions packages/plugin-list/src/ListViewBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -286,7 +327,8 @@ const ListViewBlock = elementDataSourceBlock(React.forwardRef<ListViewHandle, Li
// object list page and every other host of `ListView` are untouched.
const compact = usePageRegionPosture() === 'compact';
// The host's list destination builder, for the compact list's "View all"
// link (objectui#12703, part 2). Read only when the posture is compact.
// link (objectui#12703, part 2), read only when the posture is compact, and
// the `onCreate` it serves for the Add button (objectui#13074).
const host = useRelatedRecordActions();

// A list that names its records in neither place has nothing to fetch, and
Expand All @@ -296,6 +338,41 @@ const ListViewBlock = elementDataSourceBlock(React.forwardRef<ListViewHandle, Li
// key (objectui#12099).
// objectui#12920 — the unresolvable-view panel's title reads the bundle.
const { t } = useObjectTranslation();
const perms = usePermissions();

/**
* The audience gate (objectui#13074): `requiredPermissions` on a page
* `list-view` node is the spec's capability list, ALL required, the meaning
* the key carries on the record blocks, an app, a navigation item and an
* action. Read the way the record blocks read it (`record-details.tsx`
* writes the doctrine once): through `hasCapabilities` over the reported
* `systemPermissions`, never through the object-action path. An unheld
* capability puts the same insufficient-permissions notice (`role="status"`)
* in place of the list, and returns before the gate below, so no saved view
* is resolved and no query is sent.
*
* A provider that never reports capabilities (no provider, the role-based
* `PermissionProvider`, a backend predating ADR-0066) still opens the gate:
* `hasCapabilities`'s ruled unreported-vs-empty doctrine (objectui#4656). A
* reported empty array gates strictly.
*
* Only the page node reads it here. A list view the server serves (the
* object list page, which draws `ListView` itself) is gated by the server
* before it is served, and `ListView` reads nothing for it.
*/
const required: string[] = Array.isArray(handOff.requiredPermissions)
? handOff.requiredPermissions
: [];
if (required.length > 0 && !perms.hasCapabilities(required)) {
return (
<div className={props.className} role="status" aria-live="polite" data-testid="list-view-insufficient-permissions">
<p className="text-sm text-muted-foreground italic">
Insufficient permissions to view list.
</p>
</div>
);
}

return (
<ElementDataSourceGate
schema={handOff}
Expand All @@ -306,13 +383,27 @@ const ListViewBlock = elementDataSourceBlock(React.forwardRef<ListViewHandle, Li
defaultValue: 'This list view’s data source could not be resolved',
})}
>
{(schema) => namesRecordSource(schema)
? (
{(schema) => {
if (!namesRecordSource(schema)) return <ListViewNoObjectHint />;
// 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 (
<CompactListDestinationContext.Provider value={compact ? compactListDestination(host, schema) : null}>
<ListView ref={ref} {...props} schema={schema} dataSource={adapter} compact={compact} />
<ListView
ref={ref}
{...props}
schema={listSchema}
dataSource={adapter}
compact={compact}
onAddRecord={add.onAddRecord}
/>
</CompactListDestinationContext.Provider>
)
: <ListViewNoObjectHint />}
);
}}
</ElementDataSourceGate>
);
}));
Expand Down
Loading
Loading