Skip to content

Commit bede9de

Browse files
feat(plugin-grid): right-clicking a list row opens its actions at the pointer; a row in a multi-row selection opens the bulk actions (#28457)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuF5qzfjTC1rAZK19osv9GA3)_ domain:ui#5 · os-sales · session_01L6bhgUp9U9hou7ccEhNS13 Fixes #28434 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:列表行右键在指针处打开该行的操作菜单(Open、Open in new tab,再加上与 ⋯ 同一解析器、同一谓词/权限门/顺序/确认的条目);在 2 行以上选区内右键,则打开选区的批量操作(与 `BulkActionBar` 同源)或「N selected + Clear」。 - **为什么改**:行操作此前只能点右侧小小的 ⋯ 按钮;右键是用户已有的习惯,且不新增任何声明,只是同一组已声明操作的第二个入口。 - **风险与回滚**:链接、编辑中的单元格、输入框、选中文字、触屏长按仍走浏览器原生菜单;`DataTableSchema` 新增运行时槽位 `renderRowContextMenu`(函数,zod 镜像按名拒绝作者写入),不动 spec、不加翻译键。回滚即 revert 本 PR。 - **先看**:`ui/packages/components/src/renderers/complex/data-table.tsx` 的 `openRowContextMenu` / `keepsBrowserContextMenu` 与菜单外壳;`ui/packages/plugin-grid/src/components/RowContextMenu.tsx`;`ObjectGrid.tsx` 的 `renderRowContextMenu`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/plugin-grid/src/__tests__/rowContextMenu-28434.test.tsx packages/components/src/renderers/complex/__tests__/data-table-row-context-menu-28434.test.tsx` → 23 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes - **One resolver per menu, read twice.** `RowActionMenu` is split into `useRowActionPlan` (capability gate, switch-off, `planRowActionMenu`) and `RowActionMenuItems`; the "⋮" cell and the context menu both render from them. `BulkActionBar`'s gating moves into `useBulkActionItems`, which the bar and the selection menu both read. `objectGridActionsColumn` exposes the exact props the "⋮" cell renders with (`rowActionMenuProps(row)`), so the context menu gets the same handlers and therefore the same confirm dialogs. - **`data-table` owns the menu.** A controlled, non-modal Radix `DropdownMenu` anchored at the pointer (or below the focused cell for the context-menu key / Shift+F10), mounted only while open. Esc and a chosen item return focus to the cell it opened from. It keeps the browser's menu on `a[href]`, inputs / contenteditable, a cell in edit mode (`data-editing-cell`), a text selection inside the row, a touch long-press, and portalled menus/dialogs; and when there is nothing to offer. - **New runtime slot `DataTableSchema.renderRowContextMenu(row, { selected })`.** The host returns `DropdownMenuItem`s or `null` (browser menu). The zod mirror refuses an authored value by name (`handlerKeyRefusal`, `runtime-slot`), recorded in the KnownDrift ledger (header pin 51 / 89). - **`ObjectGrid` supplies the items.** A row in a selection of two or more gets `SelectionContextMenuItems` (count, bulk actions, Clear); any other row gets `RowContextMenuItems` (Open when the row click opens something, Open in new tab when the host publishes a record URL through `useRelatedRecordActions().recordHref`, then the row's actions). - **Related-list tables** (no host renderer) get `data-table`'s own menu: Open (the row click), then exactly that row's "⋮" items from the extracted `useDataTableRowMenuPlan`. `plugin-detail` is not edited. ## Tests - New `plugin-grid/src/__tests__/rowContextMenu-28434.test.tsx` (17), on the real `ObjectGrid` with real `ActionProvider` and a related-record host: per-row parity with the "⋮" menu (a row whose `visible` hides an action lacks it in both), the inline primary listed, Open / Open in new tab href, a custom action dispatching to its endpoint, no-host control; selection branch (bulk defs + count 3, no bulk → count + Clear and Clear clears, a row outside the selection and a selection of one get the row menu); native exceptions (link, edit cell, text selection, touch) with controls; Shift+F10, the context-menu key, Esc returns focus. - New `components/.../data-table-row-context-menu-28434.test.tsx` (6): built-in related-list menu parity and a predicate removing an item from both, Open = row click, a 2+ selection and a table with no row menu keep the browser's menu; host renderer decides alone, `null` keeps the browser's menu, keyboard + focus return. - Ablations via `scripts/ablation-replace.mjs`, each under `os-verify-lock` and restored to blob == HEAD with `git diff HEAD` empty: link exception removed (1 failed), touch exception removed (1 failed), focus return removed (1 failed), selection branch removed (2 failed), inline budget 0 → 1 (1 failed), built-in selection guard removed (1 failed). - `vitest run packages/plugin-grid/ packages/types/ packages/components/src/renderers/complex/`: 687 files, 13003 tests passed. After merging `origin/main`: `packages/types/` + the two new files + `ObjectGrid.declaredKeys-11068`: 388 files, 10475 passed. ## Screenshots (1440, app-crm Accounts) Evidence commit `438798bb` (removed again in the next commit, so nothing reaches `main`). - Row menu, same items and order as the row's "⋮" (Edit, Delete): ![row context menu](https://github.com/objectstack-ai/objectstack/blob/438798bb7606cd152f95f9839a8bbcc4f152b2e2/evidence/row-context-menu/row-context-menu-1440.png?raw=true) - Three rows selected, right-click on one: count, the bar's bulk Delete, Clear: ![selection context menu](https://github.com/objectstack-ai/objectstack/blob/438798bb7606cd152f95f9839a8bbcc4f152b2e2/evidence/row-context-menu/selection-context-menu-1440.png?raw=true) Read live in the same run: "⋮" items `["Edit","Delete"]`, context menu `["Open","Open in new tab","Edit","Delete"]`, selection menu `3 selected / Delete / Clear`. ## Acceptance notes - **Deviation (approved by the seat in the claim amendment 6546007567): inline primary actions are listed.** The "⋮" cell shows up to one primary action inline beside the trigger; a menu has no width budget, so the context menu lists every action the cell offers, primaries first where `planRowActionMenu` places them. On a row with an inline primary the context menu therefore has one more item than the "⋮" popup, never a different one. - **Related-list tables have no Open in new tab and no selection branch.** `data-table`'s built-in menu has no record URL to open (the related-list host ignores the modifier payload) and no bulk actions, so a row inside a selection of two or more keeps the browser's menu there. - **Clause-②: yes (widening)** per the claim amendment: `DataTableSchema` gains an optional runtime slot. No `packages/spec` file is touched; i18n reuses `table.open`, `detail.openInNewTab`, `grid.bulkSelected`, `grid.bulkClear` (present in all ten packs). - A full-suite run first caught a regression in the byte ruler (`ObjectGrid.declaredKeys-11068`): an always-mounted closed menu drew a Radix trigger whose generated id differed between two identical renders. The menu is now mounted only while open; the ruler passes. ## Verification Local readings at head `8a537fb0` (merge of `origin/main` `776c1d87`; source unchanged since `da55dc39` apart from the merge). - `dispatch-gates --commands`: 35 derived, each run with its exit code captured; all 35 exit 0. `--ran`: "35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN". - `node scripts/check-sdui-manifest.mjs`: exit 0. `eslint` on each changed file: 0 errors. - `type-check` (tsc --noEmit + tsconfig.test.json) for `@object-ui/types`, `@object-ui/components`, `@object-ui/plugin-grid`: exit 0. Full turbo build: exit 0. - No open PR touches any of these files (REST file lists of the open PRs; `git merge-tree` where a merge base exists). - Branch name is the harness-assigned `claude/project-thread-e7cfqb`. ## 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` passes on the changed files. - [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 generated references, changelogs or release notes. - [x] 8. Title, body and comments are in English (the maintainer summary is in Chinese by the seat's standing order). --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUp9U9hou7ccEhNS13)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUp9U9hou7ccEhNS13 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55a1feb commit bede9de

12 files changed

Lines changed: 1415 additions & 257 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
'@object-ui/components': patch
4+
'@object-ui/types': patch
5+
---
6+
7+
feat(plugin-grid): right-clicking a list row opens that row's actions at the pointer; a row inside a multi-row selection opens the selection's bulk actions
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
A list row's actions were reachable only through the small ⋯ button at the far right of the row. A right-click on the row now opens them where the pointer already is. Nothing new is declared: the menu is a second way to reach what `list_item` actions, `userActions.edit` / `delete` and the view's bulk actions already declare.
13+
14+
- **A row's menu is its ⋯ menu.** Open and Open in new tab (the record URL the row's link cell draws, when the host publishes one), then the row's actions from the same resolver the ⋯ cell reads: same capability gate, same per-record `visible` / `visibleWhen`, same order, same handlers and confirm dialogs. An action that is hidden for a row is absent from both. The inline primary buttons the cell shows beside the ⋯ are listed too, above the secondary actions, since a menu has no width budget.
15+
- **A row inside a selection of two or more opens the selection's menu.** The bulk actions `BulkActionBar` offers, from the bar's own resolver, with the selection count and Clear. It never lists a single-record action. A row outside the selection, or a selection of one, opens the row's own menu.
16+
- **The browser's menu stays where it is the useful one:** on a link in a cell, on an input or a cell in edit mode, when text is selected in the row (copying from a list keeps working), and on a touch long-press (touch keeps the ⋯ button). A grid that offers no row action at all, and whose rows cannot be selected, keeps it too; on a grid that does, a row whose own actions are all hidden by their predicates still opens Open and Open in new tab.
17+
- **Keyboard:** the context-menu key and Shift+F10 on a focused cell open the same menu below it. Esc closes it and focus goes back to the cell.
18+
- **Related-list tables** get the same menu from `data-table`'s own row menu: Open, then that row's ⋯ items. They have no record URL to open in a new tab, and no bulk actions, so a row inside a selection of two or more keeps the browser's menu there.
19+
- **New runtime slot `DataTableSchema.renderRowContextMenu`.** `(row, { selected }) => ReactNode`, supplied by the host; `data-table` owns the menu around the items it returns, and `null` keeps the browser's menu. It is a function, not metadata, so the zod mirror refuses an authored value by name.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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#28434 — the row context menu `data-table` owns.
11+
*
12+
* 1. With no host renderer, a table that draws its own row "⋮" (the
13+
* related-list tables) opens Open, then exactly that row's "⋮" items, from
14+
* the same resolver; a row inside a selection of two or more, and a table
15+
* with no row menu at all, keep the browser's menu.
16+
* 2. A host renderer (`renderRowContextMenu`) decides alone: its items, with
17+
* the row and whether it is selected; `null` keeps the browser's menu.
18+
*/
19+
20+
import { describe, it, expect, vi, afterEach } from 'vitest';
21+
import { render, screen, fireEvent, within, act, waitFor, cleanup } from '@testing-library/react';
22+
import userEvent from '@testing-library/user-event';
23+
import '@testing-library/jest-dom';
24+
import React from 'react';
25+
import { ComponentRegistry } from '@object-ui/core';
26+
import { PredicateScopeProvider } from '@object-ui/react';
27+
import { DropdownMenuItem } from '../../../ui/dropdown-menu';
28+
import '../data-table';
29+
30+
const ROWS = [
31+
{ id: 'r1', name: 'Open contract', frozen: false },
32+
{ id: 'r2', name: 'Signed contract', frozen: true },
33+
];
34+
35+
const BASE_SCHEMA = {
36+
type: 'data-table',
37+
pagination: false,
38+
searchable: false,
39+
columns: [{ header: 'Name', accessorKey: 'name' }],
40+
data: ROWS,
41+
};
42+
43+
/** The related-list shape: the table's own row "⋮" with edit, delete and a child action. */
44+
const RELATED_LIST_SCHEMA = {
45+
rowActions: true,
46+
onRowEdit: () => {},
47+
onRowDelete: () => {},
48+
rowEditPredicates: { visibleWhen: 'record.frozen != true' },
49+
rowActionDefs: [{ name: 'renew', label: 'Renew' }],
50+
onRowActionDef: () => {},
51+
};
52+
53+
function renderTable(schema: Record<string, unknown>) {
54+
const DataTable = ComponentRegistry.get('data-table') as any;
55+
if (!DataTable) throw new Error('data-table not registered');
56+
return render(
57+
<PredicateScopeProvider scope={{}}>
58+
<DataTable schema={{ ...BASE_SCHEMA, ...schema }} />
59+
</PredicateScopeProvider>,
60+
);
61+
}
62+
63+
const cellOf = (name: string) => screen.getByText(name).closest('td') as HTMLElement;
64+
const rowOf = (name: string) => screen.getByText(name).closest('tr') as HTMLElement;
65+
const contextMenu = () => screen.queryByTestId('row-context-menu');
66+
const itemIds = (menu: HTMLElement) =>
67+
within(menu).queryAllByRole('menuitem').map((el) => el.getAttribute('data-testid'));
68+
const rightClick = (el: HTMLElement) => fireEvent.contextMenu(el, { button: 2, clientX: 30, clientY: 50 });
69+
70+
async function kebabItemIds(name: string) {
71+
await userEvent.click(within(rowOf(name)).getByTestId('data-table-row-actions'));
72+
const menu = await screen.findByRole('menu');
73+
const ids = itemIds(menu);
74+
await userEvent.keyboard('{Escape}');
75+
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
76+
return ids;
77+
}
78+
79+
afterEach(() => cleanup());
80+
81+
describe('objectstack#28434 · the table\'s own row context menu (no host renderer)', () => {
82+
it('per row: Open, then exactly that row\'s "⋮" items — a false predicate removes one from both', async () => {
83+
const onRowClick = vi.fn();
84+
renderTable({ ...RELATED_LIST_SCHEMA, onRowClick });
85+
for (const row of ROWS) {
86+
const kebab = await kebabItemIds(row.name);
87+
act(() => { rightClick(cellOf(row.name)); });
88+
const menu = await screen.findByTestId('row-context-menu');
89+
const ids = itemIds(menu);
90+
expect(ids[0]).toBe('row-context-open');
91+
expect(ids.slice(1)).toEqual(kebab);
92+
expect(kebab.includes('row-action-builtin-edit')).toBe(!row.frozen);
93+
await userEvent.keyboard('{Escape}');
94+
await waitFor(() => expect(contextMenu()).toBeNull());
95+
}
96+
// Open is the row click.
97+
act(() => { rightClick(cellOf('Signed contract')); });
98+
await userEvent.click(await screen.findByTestId('row-context-open'));
99+
expect(onRowClick).toHaveBeenCalledTimes(1);
100+
expect(onRowClick.mock.calls[0][0]).toMatchObject({ id: 'r2' });
101+
});
102+
103+
it('a row inside a selection of two or more keeps the browser\'s menu: this table has no bulk actions to offer', async () => {
104+
renderTable({ ...RELATED_LIST_SCHEMA, selectable: 'multiple' });
105+
for (const name of ['Open contract', 'Signed contract']) {
106+
await userEvent.click(within(rowOf(name)).getByRole('checkbox'));
107+
}
108+
expect(rightClick(cellOf('Open contract'))).toBe(true);
109+
expect(contextMenu()).toBeNull();
110+
});
111+
112+
it('CONTROL — a table with no row menu keeps the browser\'s menu, even with a row click', () => {
113+
renderTable({ onRowClick: () => {} });
114+
expect(rightClick(cellOf('Open contract'))).toBe(true);
115+
expect(contextMenu()).toBeNull();
116+
});
117+
});
118+
119+
describe('objectstack#28434 · a host renderer decides alone', () => {
120+
it('draws the host\'s items, given the row and whether it is selected', async () => {
121+
const renderRowContextMenu = vi.fn((row: any) => (
122+
<DropdownMenuItem data-testid="host-item">{`Host item for ${row.id}`}</DropdownMenuItem>
123+
));
124+
renderTable({ ...RELATED_LIST_SCHEMA, renderRowContextMenu });
125+
let allowed = true;
126+
act(() => { allowed = rightClick(cellOf('Signed contract')); });
127+
expect(allowed).toBe(false);
128+
const menu = await screen.findByTestId('row-context-menu');
129+
// The host's items only — the table's own are not mixed in.
130+
expect(itemIds(menu)).toEqual(['host-item']);
131+
expect(menu).toHaveTextContent('Host item for r2');
132+
expect(renderRowContextMenu).toHaveBeenCalledWith(expect.objectContaining({ id: 'r2' }), { selected: false });
133+
});
134+
135+
it('a host answering null keeps the browser\'s menu', () => {
136+
renderTable({ ...RELATED_LIST_SCHEMA, renderRowContextMenu: () => null });
137+
expect(rightClick(cellOf('Open contract'))).toBe(true);
138+
expect(contextMenu()).toBeNull();
139+
});
140+
141+
it('the keyboard opens it below the focused cell; Esc closes it and returns focus to that cell', async () => {
142+
renderTable({
143+
renderRowContextMenu: () => <DropdownMenuItem data-testid="host-item">Host item</DropdownMenuItem>,
144+
});
145+
const cell = cellOf('Open contract');
146+
cell.focus();
147+
act(() => { fireEvent.keyDown(cell, { key: 'F10', shiftKey: true }); });
148+
expect(await screen.findByTestId('row-context-menu')).toBeInTheDocument();
149+
await userEvent.keyboard('{Escape}');
150+
await waitFor(() => expect(contextMenu()).toBeNull());
151+
expect(cell).toHaveFocus();
152+
});
153+
});

0 commit comments

Comments
 (0)