Skip to content

Commit 6c30357

Browse files
fix(plugin-list): a non-grid view's selection bar gates a declared bulk action by its requiredPermissions and visible, through the grid bar's own gate (#30535) (#30580)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu1CUE4ix29CbZXPy9zCAEqW)_ domain:ui#4 · os-sales · session_01L6bhgWBF1SZ4drR5vh5BSM Fixes #30535 Clause-②: yes (widening) — `@object-ui/plugin-grid` now exports `useBulkActionItems` (with the `BulkActionItems` and `LegacyBulkActionItem` types its signature names), the grid selection bar's own gate, so `@object-ui/plugin-list` reuses it instead of a second copy. Its `actionDefs` input now reads `readonly BulkActionDef[]`, which accepts every list it accepted before. Metadata-protocol: none ## 维护者速读 - 改了什么:看板、日历、画廊等非表格视图的选择栏,画对象声明的批量操作前先过表格选择栏同一道门:用户不具备 `requiredPermissions` 的不画;被停用的不画;`visible` 按每条选中记录求值,至少一条通过才画。`bulkActions` 里重复的名字只画一个按钮(原来两个按钮同一个 React key)。读不到对象 schema 时的开发态警告,处方句改为点名组件(`ListView` / `ObjectGrid`),不再一律写「the grid」。 - 为什么改:PR #30498 之后非表格选择栏已拿到解析后的操作定义,却只读了标签,于是带权限或 `visible` 条件的操作对所有人、所有选择都显示;表格那边两道门都有。 - 风险与回滚:plugin-grid 入口多导出一个 hook 和两个类型,入参放宽为只读数组;表格选择栏与右键菜单行为不变。服务端动作入口本来就拒绝缺权限的调用(实测 403),本改动只是界面不再画出注定被拒的按钮。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-list/src/ListView.tsx`(`nonGridBulkFold` / `nonGridBulkItems` / `nonGridBulkBar` 三段)→ `ui/packages/plugin-grid/src/index.tsx`(新导出)→ `ui/packages/plugin-grid/src/components/BulkActionBar.tsx`(只读入参)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-list/src/__tests__/ListView.nonGridBulkGate-30535.test.tsx`,预期 6 条全过;消融门禁时其中 2 条转红,消融去重时 1 条转红。 - 需要你做的:无,席位落地。 ## What changes ListView draws its own selection bar for every view but the grid. Since #30394 it folds `bulkActions` through plugin-grid's `resolveBulkActions` and so holds each resolved action definition, `requiredPermissions` and `visible` included, but it read only the label. This passes the fold through the grid bar's own gate. - **`plugin-list/src/ListView.tsx`.** The former `nonGridBulkBar` memo is split in three. `nonGridBulkFold` dedupes the names (first occurrence wins; the built-in `delete` is one button whatever its case) and folds them through `resolveBulkActions` exactly as before. `nonGridBulkItems` hands the folded defs to plugin-grid's `useBulkActionItems`, the hook the grid's `BulkActionBar` and its right-click `RowContextMenu` already read: `requiredPermissions` against the caller's held capabilities (`useCapabilityGate`, unknown fails open), the activation switch-off (`isActionSwitchedOff`), and `visible` once per selected record, offered when at least one passes. `nonGridBulkBar` then draws, in authored order, `delete` (its own path, gate and `visibleWhen` subset, unchanged) and each name the gate offers. The undeclared-name warning reads the fold's `undeclared` as before. - **`plugin-grid/src/index.tsx`.** Exports `useBulkActionItems` and the `BulkActionItems` / `LegacyBulkActionItem` types. - **`plugin-grid/src/components/BulkActionBar.tsx`.** `useBulkActionItems`'s `actionDefs` reads `readonly BulkActionDef[]` (the fold's own return type); the hook only filters it. No behaviour change. - **`plugin-grid/src/undeclaredActionNames.ts`.** The `unread` prescription says "Give the ListView a data source…" or "Give the ObjectGrid a data source…", from the address's `component`, instead of "Give the grid…". ## Why The card's first measurement, the run door: `POST /api/v1/actions/OBJECT/ACTION` refuses a declared action whose `requiredPermissions` the caller lacks with 403 before anything dispatches (`packages/runtime/src/domains/actions.ts`, `actionPermissionError`). The existing pins say so on this head: `pnpm --filter @objectstack/runtime exec vitest run src/http-dispatcher.test.ts -t 'ADR-0066 D4 action requiredPermissions gate'` gives `Tests 5 passed | 246 skipped`. So the gap here is the bar drawing a button the door will refuse, not a door that lets the call through. ## Tests - New `ui/packages/plugin-list/src/__tests__/ListView.nonGridBulkGate-30535.test.tsx` (gallery view, selection stubbed as in the #30394 test): an action requiring a capability the caller lacks is not drawn; drawn when held; drawn when capabilities are unknown (fail open, the grid's rule); `visible: "record.status == 'closed'"` draws nothing over an open-only selection and draws over a mixed one; `['tag_lead', 'tag_lead', 'delete', 'delete']` draws one of each with no duplicate-key error. - `ui/packages/plugin-grid/src/__tests__/undeclaredActionNames.test.ts`: the `unread` prescription names `ObjectGrid` by default and `ListView` from the address. ## Acceptance notes - What the bar dispatches is unchanged and out of scope (the card's "Not in scope", the #30387 review): it still calls the host's `onBulkAction(name, selectedRows)` with the whole selection, where the grid runs a per-record-`visible` def over the passing subset only. The bar now draws such an action only when one selected record passes; which records the host receives is left to that ruling. - The console's app-shell wires no `onBulkAction` on this bar (`ObjectView.relayRungCensus-7559.test.ts` records it as host-runtime, delete only), so in the console a non-delete button here still has no host behind it; unchanged by this PR. ## Verification All on `9eda24eb` unless stated. Closure: `pnpm exec turbo run build --filter='@objectstack/runtime^...' --filter='@object-ui/plugin-list^...' --filter='@object-ui/plugin-grid^...' --concurrency=2` (53 tasks, lock `VERDICT command-exit 0`), then `pnpm --filter @object-ui/plugin-grid build` after the readonly change. <details><summary>Package suites and type-check</summary> - `pnpm exec vitest run --maxWorkers=2 packages/plugin-list/ packages/plugin-grid/` (from `ui/`, on `5f1f79b6`; `9eda24eb` adds only the readonly parameter type and the changeset level): `Test Files 469 passed (469)`, `Tests 4218 passed (4218)`. - On `9eda24eb`: the new test plus `BulkActionBar.test.tsx`, `bulkActionCapabilityGate.test.tsx`, `bulk-action-visible.test.tsx`: `Tests 32 passed (32)`, `VERDICT command-exit 0`. - `pnpm --filter @object-ui/plugin-grid --filter @object-ui/plugin-list run type-check`: plugin-list first red on `5f1f79b6` (`TS4104`: the fold's `readonly BulkActionDef[]` into the hook's mutable parameter), read through the rebuilt plugin-grid `.d.ts`; both `Done` on `9eda24eb` after `pnpm --filter @object-ui/plugin-grid build`. - `pnpm --dir ui run check:spec-symbols` :: exit 0. </details> <details><summary>Ablations (three, each on a tested behaviour, via scripts/ablation-replace.mjs)</summary> The test imports ListView and plugin-grid from `src` (ui vitest aliases `@object-ui/*` to `src`), so no dist leg. 1. Gate: anchor `const labels = new Map(nonGridBulkItems.defs.map(` replaced by `const labels = new Map(nonGridBulkFold.defs.map(` in `ListView.tsx` (anchor 1 → 0, blob `2675189af304` → `7ed74bffb459`): `Tests 2 failed | 4 passed (6)` (the lacking-capability case and the no-passing-record case). Restored: blob == HEAD, `git diff HEAD` empty. 2. Dedupe: anchor `if (seen.has(key)) return false;` deleted (blob `2675189af304` → `4a39ec23f849`): `Tests 1 failed | 5 passed (6)` (the repeated-name case). Restored, `git diff HEAD` empty. 3. Wording: the prescription's component interpolation replaced by `Give the grid a data source` in `undeclaredActionNames.ts` (blob `baf3f36dd5d4` → `3602646c8b8c`): `Tests 1 failed | 5 passed (6)`. Restored, `git diff HEAD` empty. </details> <details><summary>Derived gates (dispatch-gates --commands, 35 families)</summary> - Derived set identical on `5f1f79b6` and `9eda24eb` (35 families). - `--ran` with an exit code on every line: see the os-dev-report on the card for the final-head reconciliation. - `check-changeset-no-major` fed this body's declaration lines as the event: exit 1 with the changeset at `minor` (`Metadata-protocol: none` takes `patch`), exit 0 at `patch` (`5c534bc9`). - Families CI runs are read from this PR's check-runs. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English, except the 维护者速读 section the fleet requires. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgWBF1SZ4drR5vh5BSM --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgWBF1SZ4drR5vh5BSM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bfbdeec commit 6c30357

7 files changed

Lines changed: 224 additions & 13 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
'@object-ui/plugin-grid': patch
4+
---
5+
6+
fix(plugin-list): a list's selection bar over a non-grid view (kanban, calendar, gallery, …) draws a declared bulk action only for a user who holds its `requiredPermissions` and only when its `visible` admits a selected record, through the grid bar's own gate (#30535)
7+
8+
Clause-②: yes (widening) — `@object-ui/plugin-grid` exports `useBulkActionItems` (with the `BulkActionItems` and `LegacyBulkActionItem` types), the grid selection bar's gate, so `@object-ui/plugin-list` reuses it instead of a second copy.
9+
Metadata-protocol: none
10+
11+
- **Before.** The bar ListView draws itself for every view but the grid folded `bulkActions` through the grid's `resolveBulkActions` and kept only each action's label. An action declaring `requiredPermissions` drew for every user, and one declaring `visible` drew for every selection, while the grid's bar hid both. A name repeated in `bulkActions` drew two buttons under one React key.
12+
- **Now.** The bar passes the resolved actions through `useBulkActionItems`, the hook the grid's bar and its right-click menu read: an action whose `requiredPermissions` the user does not hold is not drawn (unknown capabilities fail open, as on the grid), an action switched off is not drawn, and `visible` is evaluated once per selected record, drawing the action when at least one selected record passes. A repeated name draws one button. The development warning for an object whose schema could not be read now names the component it came from (`ListView` or `ObjectGrid`) instead of "the grid".
13+
- **Remedy.** Nothing to change in metadata. A user who should see a gated action needs the capability it declares.

‎ui/packages/plugin-grid/src/__tests__/undeclaredActionNames.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ describe('describeUndeclaredActionNames', () => {
6969
expect(list).toContain('the selection bar draws no button');
7070
});
7171

72+
it('names the component from the address in the unread prescription, not "the grid" (objectstack#30535)', () => {
73+
const grid = describeUndeclaredActionNames('bulkActions', ['a'], ADDRESS, 'unread');
74+
expect(grid).toContain('Give the ObjectGrid a data source that answers getObjectSchema');
75+
const list = describeUndeclaredActionNames('bulkActions', ['a'], {
76+
component: 'ListView', blockType: 'calendar', objectName: 'lead',
77+
}, 'unread');
78+
expect(list).toContain('Give the ListView a data source that answers getObjectSchema');
79+
expect(list).not.toContain('the grid');
80+
});
81+
7282
it('says nothing for no names', () => {
7383
expect(describeUndeclaredActionNames('bulkActions', [], ADDRESS)).toBeNull();
7484
});

‎ui/packages/plugin-grid/src/components/BulkActionBar.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ export interface BulkActionItems {
8787
export function useBulkActionItems(input: {
8888
selectedRows: any[];
8989
actions: string[] | undefined;
90-
actionDefs?: BulkActionDef[];
90+
/** Read, never mutated: `resolveBulkActions` hands its fold over as a readonly list. */
91+
actionDefs?: readonly BulkActionDef[];
9192
objectFields?: unknown;
9293
}): BulkActionItems {
9394
const { selectedRows, actions, actionDefs, objectFields } = input;

‎ui/packages/plugin-grid/src/index.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ export { useGroupedData } from './useGroupedData';
3232
export { GroupRow } from './GroupRow';
3333
export { RowActionMenu, formatActionLabel } from './components/RowActionMenu';
3434
export { BulkActionBar } from './components/BulkActionBar';
35-
// The bare-name fold and its development warning, for the one other surface
36-
// that draws a selection bar of its own: plugin-list's ListView over a
37-
// non-grid view (objectstack#30394). One rule, one copy.
35+
// The bare-name fold, its development warning and the bar's own gate (the
36+
// capability check, the switch-off and the per-record `visible`), for the one
37+
// other surface that draws a selection bar of its own: plugin-list's ListView
38+
// over a non-grid view (objectstack#30394, objectstack#30535). One rule, one copy.
39+
export { useBulkActionItems } from './components/BulkActionBar';
3840
export { resolveBulkActions } from './resolveBulkActions';
3941
export { useUndeclaredActionNamesWarning } from './undeclaredActionNames';
4042
export { useCellClipboard } from './useCellClipboard';
@@ -59,7 +61,7 @@ export type { ImportWizardProps, ImportResult } from './ImportWizard';
5961
export type { GroupEntry, UseGroupedDataResult, AggregationType, GroupAggregationConfig, AggregationResult } from './useGroupedData';
6062
export type { GroupRowProps } from './GroupRow';
6163
export type { RowActionMenuProps } from './components/RowActionMenu';
62-
export type { BulkActionBarProps } from './components/BulkActionBar';
64+
export type { BulkActionBarProps, BulkActionItems, LegacyBulkActionItem } from './components/BulkActionBar';
6365
export type { ActionLabelResolver, NamedActionDef } from './resolveBulkActions';
6466
export type {
6567
UndeclaredActionNameKey,

‎ui/packages/plugin-grid/src/undeclaredActionNames.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export function describeUndeclaredActionNames(
108108
return `[ObjectUI] ${describeComponent(address.component)} ${key}: ${describeAddress(address)} — the object's schema could not `
109109
+ `be read (no getObjectSchema on the data source, or the read failed), so its declared actions `
110110
+ `are unknown and ${SURFACE[key]} for ${count}: ${listed}.\n`
111-
+ `${rule} Give the grid a data source that answers getObjectSchema, or check why the read failed.`;
111+
+ `${rule} Give the ${describeComponent(address.component)} a data source that answers getObjectSchema, or check why the read failed.`;
112112
}
113113
const count = names.length === 1 ? '1 name matches' : `${names.length} names match`;
114114
return `[ObjectUI] ${describeComponent(address.component)} ${key}: ${describeAddress(address)} — ${count} no action the object `

‎ui/packages/plugin-list/src/ListView.tsx‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import { platformProvisionsStorage, resolveInjectedSystemColumns, resolveSearchF
5454
import { SystemFieldName } from '@objectstack/spec/system';
5555
import { usePermissions } from '@object-ui/permissions';
5656
import { RecordDetailPanel } from '@object-ui/plugin-detail';
57-
import { resolveBulkActions, useUndeclaredActionNamesWarning } from '@object-ui/plugin-grid';
57+
import { resolveBulkActions, useBulkActionItems, useUndeclaredActionNamesWarning } from '@object-ui/plugin-grid';
5858

5959
/**
6060
* The `case 'map'` branch below builds an `object-map` schema by flattening
@@ -2558,9 +2558,25 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
25582558
* below. Before this fold every member drew a button, so a name the object
25592559
* never declared, an action a disabled package withholds included, showed a
25602560
* button that only toasted "not executable" when pressed.
2561+
*
2562+
* [objectstack#30535] A declared action then passes the grid bar's OWN gate,
2563+
* `useBulkActionItems`, before it draws: its `requiredPermissions` (ADR-0066
2564+
* D4, the caller's held capabilities), its activation switch, and its
2565+
* `visible`, evaluated once per selected record and offered when at least
2566+
* one record passes. The fold carried the resolved definition all along; the
2567+
* bar read only its label, so a gated action drew for every user and every
2568+
* selection. A repeated name draws once, as the fold surfaces it once.
25612569
*/
2562-
const nonGridBulkBar = React.useMemo(() => {
2563-
const names = (permittedBulkActions ?? []).filter((a: unknown) => typeof a === 'string' && a !== '') as string[];
2570+
const nonGridBulkFold = React.useMemo(() => {
2571+
const seen = new Set<string>();
2572+
const names = ((permittedBulkActions ?? []) as unknown[]).filter((a): a is string => {
2573+
if (typeof a !== 'string' || a === '') return false;
2574+
// The built-in `delete` is one button whatever its spelling's case.
2575+
const key = a.toLowerCase() === 'delete' ? 'delete' : a;
2576+
if (seen.has(key)) return false;
2577+
seen.add(key);
2578+
return true;
2579+
});
25642580
const { defs, undeclared } = resolveBulkActions({
25652581
bulkActions: names.filter((a) => a.toLowerCase() !== 'delete'),
25662582
objectActions: objectDef?.actions,
@@ -2569,13 +2585,22 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
25692585
? resolveActionLabel(schema.objectName, actionName, fallback)
25702586
: fallback,
25712587
});
2572-
const labels = new Map(defs.map((def) => [def.name, def.label]));
2588+
return { names, defs, undeclared };
2589+
}, [permittedBulkActions, objectDef, schema.objectName, resolveActionLabel]);
2590+
const nonGridBulkItems = useBulkActionItems({
2591+
selectedRows,
2592+
actions: undefined,
2593+
actionDefs: nonGridBulkFold.defs,
2594+
objectFields: objectDef?.fields,
2595+
});
2596+
const nonGridBulkBar = React.useMemo(() => {
2597+
const labels = new Map(nonGridBulkItems.defs.map((def) => [def.name, def.label]));
25732598
// Authored order, the built-in `delete` where the author put it.
2574-
const drawn = names
2599+
const drawn = nonGridBulkFold.names
25752600
.filter((a) => a.toLowerCase() === 'delete' || labels.has(a))
25762601
.map((name) => ({ name, label: labels.get(name) }));
2577-
return { drawn, undeclared };
2578-
}, [permittedBulkActions, objectDef, schema.objectName, resolveActionLabel]);
2602+
return { drawn, undeclared: nonGridBulkFold.undeclared };
2603+
}, [nonGridBulkFold, nonGridBulkItems]);
25792604
// The grid says its own (ObjectGrid warns for the names it leaves undrawn),
25802605
// so this one speaks only while a non-grid view is on screen, and only once
25812606
// the object's schema read has settled. A list with an object but no schema
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#30535 — the NON-grid selection bar (kanban / calendar / gallery
11+
* / …) applies the grid bar's own gate to a declared bulk action, through the
12+
* grid's own `useBulkActionItems`:
13+
*
14+
* - an action whose `requiredPermissions` the caller does not hold is not
15+
* drawn (ADR-0066 D4; unknown capabilities fail open, as on the grid);
16+
* - `visible` is evaluated once per selected record, and the action is drawn
17+
* when at least one selected record passes;
18+
* - a name repeated in `bulkActions` draws one button.
19+
*
20+
* The grid's half is pinned in plugin-grid's `bulkActionCapabilityGate.test.tsx`
21+
* and `bulk-action-visible.test.tsx`; this file pins the same gate on the bar
22+
* ListView renders for every other view.
23+
*/
24+
25+
import React from 'react';
26+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
27+
import { render, waitFor, screen, cleanup } from '@testing-library/react';
28+
import { ComponentRegistry } from '@object-ui/core';
29+
import { ActionProvider, SchemaRendererProvider } from '@object-ui/react';
30+
import type { ListViewSchema } from '@object-ui/types';
31+
import { ListView } from '../ListView';
32+
33+
type Row = { id: string; name: string; status: string };
34+
35+
function makeDataSource(objectName: string, rows: Row[], actions: unknown[]) {
36+
return {
37+
find: vi.fn().mockResolvedValue(rows.map(r => ({ ...r }))),
38+
findOne: vi.fn(),
39+
create: vi.fn(),
40+
update: vi.fn(),
41+
delete: vi.fn(),
42+
getObjectSchema: vi.fn().mockResolvedValue({
43+
name: objectName,
44+
fields: { id: { type: 'text' }, name: { type: 'text' }, status: { type: 'text' } },
45+
actions,
46+
}),
47+
};
48+
}
49+
50+
/** A gallery stand-in that selects `rows` as soon as it mounts. */
51+
function registerSelectingGallery(rows: Row[]) {
52+
ComponentRegistry.register('object-gallery', (props: any) => {
53+
const onRowSelect = props.onRowSelect;
54+
React.useEffect(() => {
55+
onRowSelect?.(rows);
56+
}, [onRowSelect]);
57+
return <div data-testid="gallery-stub" />;
58+
});
59+
}
60+
61+
function renderGallery(
62+
objectName: string,
63+
bulkActions: string[],
64+
ds: ReturnType<typeof makeDataSource>,
65+
user?: unknown,
66+
) {
67+
const schema: ListViewSchema = {
68+
type: 'list-view',
69+
objectName,
70+
viewType: 'gallery',
71+
fields: ['name'],
72+
bulkActions,
73+
};
74+
const tree = (
75+
<SchemaRendererProvider dataSource={ds as any}>
76+
<ListView schema={schema} dataSource={ds as any} onBulkAction={vi.fn()} />
77+
</SchemaRendererProvider>
78+
);
79+
return render(user === undefined ? tree : <ActionProvider context={{ user } as any}>{tree}</ActionProvider>);
80+
}
81+
82+
const OPEN: Row = { id: 'l1', name: 'Lead One', status: 'open' };
83+
const CLOSED: Row = { id: 'l2', name: 'Lead Two', status: 'closed' };
84+
85+
const ACTIONS = [
86+
{ name: 'tag_lead', label: 'Tag' },
87+
{ name: 'push_down', label: 'Push Down', requiredPermissions: ['plan.push'] },
88+
{ name: 'reopen', label: 'Reopen', visible: "record.status == 'closed'" },
89+
];
90+
91+
describe('ListView non-grid bulk bar applies the grid bar gate (objectstack#30535)', () => {
92+
let prevGallery: ReturnType<typeof ComponentRegistry.get>;
93+
94+
beforeEach(() => {
95+
prevGallery = ComponentRegistry.get('object-gallery');
96+
vi.spyOn(console, 'warn').mockImplementation(() => {});
97+
});
98+
99+
afterEach(() => {
100+
cleanup();
101+
vi.restoreAllMocks();
102+
if (prevGallery) ComponentRegistry.register('object-gallery', prevGallery);
103+
else ComponentRegistry.unregister('object-gallery');
104+
});
105+
106+
it('does not draw an action whose requiredPermissions the caller lacks', async () => {
107+
registerSelectingGallery([OPEN]);
108+
const ds = makeDataSource('lead_g1', [OPEN], ACTIONS);
109+
renderGallery('lead_g1', ['tag_lead', 'push_down'], ds, { id: 'u1', systemPermissions: ['setup.access'] });
110+
111+
await screen.findByTestId('bulk-action-tag_lead');
112+
expect(screen.queryByTestId('bulk-action-push_down')).toBeNull();
113+
});
114+
115+
it('draws it when the caller holds every declared capability', async () => {
116+
registerSelectingGallery([OPEN]);
117+
const ds = makeDataSource('lead_g2', [OPEN], ACTIONS);
118+
renderGallery('lead_g2', ['tag_lead', 'push_down'], ds, { id: 'u1', systemPermissions: ['plan.push'] });
119+
120+
expect(await screen.findByTestId('bulk-action-push_down')).toHaveTextContent('Push Down');
121+
});
122+
123+
it('draws it when the caller capabilities are unknown (fail open, as the grid)', async () => {
124+
registerSelectingGallery([OPEN]);
125+
const ds = makeDataSource('lead_g3', [OPEN], ACTIONS);
126+
renderGallery('lead_g3', ['push_down'], ds);
127+
128+
expect(await screen.findByTestId('bulk-action-push_down')).toBeInTheDocument();
129+
});
130+
131+
it('evaluates visible per selected record: no passing record, no button', async () => {
132+
registerSelectingGallery([OPEN]);
133+
const ds = makeDataSource('lead_v1', [OPEN], ACTIONS);
134+
renderGallery('lead_v1', ['tag_lead', 'reopen'], ds);
135+
136+
await screen.findByTestId('bulk-action-tag_lead');
137+
expect(screen.queryByTestId('bulk-action-reopen')).toBeNull();
138+
});
139+
140+
it('evaluates visible per selected record: one passing record draws the button', async () => {
141+
registerSelectingGallery([OPEN, CLOSED]);
142+
const ds = makeDataSource('lead_v2', [OPEN, CLOSED], ACTIONS);
143+
renderGallery('lead_v2', ['tag_lead', 'reopen'], ds);
144+
145+
expect(await screen.findByTestId('bulk-action-reopen')).toHaveTextContent('Reopen');
146+
});
147+
148+
it('draws a repeated name once, with no duplicate React key', async () => {
149+
registerSelectingGallery([OPEN]);
150+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
151+
const ds = makeDataSource('lead_d1', [OPEN], ACTIONS);
152+
renderGallery('lead_d1', ['tag_lead', 'tag_lead', 'delete', 'delete'], ds);
153+
154+
await screen.findByTestId('bulk-action-tag_lead');
155+
await waitFor(() => expect(screen.getAllByTestId('bulk-action-tag_lead')).toHaveLength(1));
156+
expect(screen.getAllByTestId('bulk-action-delete')).toHaveLength(1);
157+
const keyErrors = error.mock.calls.filter(c => String(c[0]).includes('same key'));
158+
expect(keyErrors).toEqual([]);
159+
});
160+
});

0 commit comments

Comments
 (0)