Skip to content

Commit d5ab8fc

Browse files
fix(plugin-grid): on a phone, each list card offers its row's actions through one 44 px menu (#29301)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbUxcj8HNVLVidnzZ1WNvjyD)_ Fixes #29150 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:手机宽度(768 px 以下,列表画成卡片)时,每张卡片右侧多了一个 44 px 的「⋮」按钮,点开就是这一行在桌面上的全部行操作(编辑、删除、对象声明的行操作),顺序与桌面行的右键菜单一致。点卡片其它位置仍然打开记录。这一行没有任何可用操作时不画按钮。桌面表格不变。 - **为什么改**:#29074 在 375 / 390 px 实测:手机卡片上没有任何行操作入口,长按也只是打开记录。手机用户要编辑或删除一条记录必须先进记录页,只在列表上提供的行操作(`list_item`)根本够不到。 - **风险与回滚**:低。只改 `ObjectGrid.tsx` 卡片渲染里一处:复用桌面行的 `RowActionMenu` 和 `rowActionMenuProps(row)`(同一套权限、谓词、处理函数和确认框),内联预算设为 0,所以所有操作都在一个菜单里。按钮外层用 `-my-2`,实测卡片高度不变(62 px)。不加键、不加 prop、不加导出、不加文案。回滚即回退本 PR。 - **先看**:`ui/packages/plugin-grid/src/ObjectGrid.tsx` 的 `buildMobileCardRenderer` 末尾 `grid-card-actions` 那一段(只在 `rowOffersAction(row)` 时绘制);新测试 `ui/packages/plugin-grid/src/__tests__/phoneCardRowActions-29150.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/plugin-grid/src/__tests__/phoneCardRowActions-29150.test.tsx`,预期 4 个用例全部通过;在 `main` 上同一文件 3 个失败。 - **需要你做的**:无,席位落地。 Claim: issuecomment-6574754357. ## What changes Below the 768 px card breakpoint, `ObjectGrid` draws each row as a card (the #28484 layout: title, one subtitle line, one status chip). A card had no action control, so a phone reader had to open each record to edit, delete or run a declared row action, and an action offered only on the list (`list_item`) could not be reached at all. - **`ObjectGrid`** (plugin-grid), `buildMobileCardRenderer`: each card whose row offers an action ends with the row's own `RowActionMenu`, rendered from `rowActionMenuProps(row)`, the props the desktop row's "⋮" cell and its context menu already read. So it applies the same capability gate, per-record predicates, record-level verdicts, handlers and confirm steps. - The inline budget is `0` (`maxInlineActions={0}`), the value the desktop row's context menu passes: every action sits in the one menu, in the plan's order (Edit, Delete, primary actions, other actions, legacy actions). On desktop the same set shows in the same order, except that up to one primary action is drawn as a button left of the "⋮". - The trigger is `RowActionMenu`'s existing one, 44×44 below `sm`. Its wrapper stops the click, so a tap on the control does not open the record, and a tap anywhere else on the card still does. The card's wrapper box takes `-my-2`, so the 44 px target does not make the card taller. - A card whose row offers nothing (`rowOffersAction(row)` false) draws no control. The desktop table, the row context menu and the metadata are unchanged. No key, prop, export or locale string is added; the trigger's screen-reader label is the existing `grid.openMenu`. ## Tests - `phoneCardRowActions-29150.test.tsx` (new), on the real `ObjectGrid` with Edit, Delete and three `list_item` actions (one primary, one gated by `visible`): - At 1280 px, the row context menu is read as the reference: Edit, Delete, Promote (primary), Submit for review (draft rows only), Archive. At 390 px, each card's menu lists exactly that row's reference, in the same order, and no inline button is drawn on a card. - One trigger per card, inside the card's last box, with the 44 px classes. - Choosing Edit from a card's menu calls `onEdit` with that row and does not call `onRowClick`. A tap on another card's title calls `onRowClick` with that row. - Control: a grid with no action draws no trigger on any card. - Red on `main` (0c0d3b6): the first three cases fail ("Unable to find an element by: [data-testid="row-action-trigger"]"), the control passes. - Ablation (one time, not kept): `maxInlineActions={0}` changed to `maxInlineActions={1}` through `scripts/ablation-replace.mjs` reds the order case (the primary moves onto the card as an inline button). The restore left `git diff HEAD` empty. A first attempt replaced the anchor with a string that already occurred 41 times; the tool refused it as a no-op, so it did not run. - Neighbours: `phoneCardRow-28484.test.tsx` and `rowContextMenu-28434.test.tsx` pass unchanged (29/29 with the new file). ## Acceptance notes Live measurement, Console production build of this branch (`pnpm console:build` at 49c4abf), served at `/_console` by `pnpm dev:crm -- --seed-admin --fresh`. Chromium with `isMobile` and `hasTouch`, DPR 2, at 390×844 and 375×812; desktop reference at 1280×900. Script and readings: `/mnt/project-files/issue-29150/measure.cjs`, `after/measure.json` (`crm_lead`) and `after/measure-account.json` (`crm_account`). | Check (`crm_lead`, which declares the `list_item` action Convert Lead) | 390 | 375 | | --- | --- | --- | | Each card has one row-action control | yes, 44×44, inside the card | yes, 44×44, inside the card | | Card height with / without the control | 62 / 62 px | 62 / 62 px | | Card menu equals the desktop row's actions, same order | Edit, Delete, Convert Lead = desktop "⋮" and context menu | same | | Tap on the control opens the record | no (URL unchanged) | no | | Tap on the card opens the record | yes | yes | | Menu fits the viewport; no horizontal page scroll | yes; 390 = 390 | yes; 375 = 375 | | Title width beside the chip and the control | 200 px on a lead card; 292 px on an account card | 277 px on an account card | `crm_account` (Edit and Delete only) reads the same: 44×44 control, card 62 px either way, menu equal to the desktop menu, the card tap opens the record. Before this change (`main` a4015ab, #29074's run record, check L06) a card held no control and a long press opened the record. Out of scope, reported to the seat: the menu's items are 32 px tall at phone width. That is the shared dropdown item, the same in every phone menu, not this card's control. ## Verification All local readings are on head 49c4abf. <details><summary>Touched package and neighbours</summary> - `pnpm --dir ui exec vitest run packages/plugin-grid/src/__tests__/phoneCardRowActions-29150.test.tsx packages/plugin-grid/src/__tests__/phoneCardRow-28484.test.tsx packages/plugin-grid/src/__tests__/rowContextMenu-28434.test.tsx`: 3 files, 29 tests passed. - The plugin-grid test files that import `ObjectGrid` (`git grep` set, 163 files) at `--maxWorkers=2` in four batches: 41 + 41 + 41 + 40 files passed, 269 + 372 + 286 + 360 = 1287 tests passed. CI's Test UI runs the rest of the package. - `pnpm exec turbo run type-check --filter=@object-ui/plugin-grid`: exit 0 (`tsc --noEmit && tsc -p tsconfig.test.json`; `--listFilesOnly` on the test project lists the new file). - `eslint` on the two changed ui files: 0 errors (warnings only, the file's existing `any`). - `pnpm --dir ui run check:new-line-citations`: 0 new citations. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` derived 35 families for this diff; each ran locally on 49c4abf and exited 0. `dispatch-gates --ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 ``` `vitest run scripts/` read 169 files passed, 2 skipped (4900 tests passed). The type-check lanes and the wide-population families are CI's. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; 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` not touched. - [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 (the 维护者速读 section is the maintainer summary the lane asks for). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jVMaEeoiKQgHPaTy3Z)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 40eab63 commit d5ab8fc

3 files changed

Lines changed: 244 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
---
4+
5+
fix(plugin-grid): on a phone, each list card offers its row's actions through one 44 px "⋮" control
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Below the 768 px card breakpoint, `ObjectGrid` draws its rows as cards, and a card had no way to reach the row's actions: a phone reader had to open each record to edit, delete or run a declared row action, and an action offered only on the list could not be reached at all. Each card whose row offers an action now ends with the row's own "⋮" menu, 44 px on a phone. The menu reads the same resolver, handlers and confirm steps as the desktop row, and lists every action in one menu in the order the desktop row's context menu lists them: Edit, Delete, the primary actions, the other actions. A tap on the card still opens the record. A card whose row offers nothing draws no control. The desktop table and the metadata are unchanged.
11+
12+
What to do: nothing.

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ import { useRecordCrudVerdicts } from './hooks/useRecordCrudVerdicts';
7474
import { resolveLegacyRowActions } from './resolveLegacyRowActions';
7575
import { resolveBulkActions, resolvePromotedBulkParams } from './resolveBulkActions';
7676
import { partitionBulkRows } from './bulkEligibility';
77-
import { formatActionLabel } from './components/RowActionMenu';
77+
import { RowActionMenu, formatActionLabel } from './components/RowActionMenu';
7878
import { BulkActionBar } from './components/BulkActionBar';
7979
import { RowContextMenuItems, SelectionContextMenuItems } from './components/RowContextMenu';
8080
import { BulkActionDialog } from './components/BulkActionDialog';
@@ -3315,6 +3315,16 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
33153315
{cardCell(statusCol, row)}
33163316
</div>
33173317
)}
3318+
{/* objectstack#29150 — the row's actions, the desktop row's own
3319+
resolver and handlers in one 44 px "⋮" (inline budget 0, as the
3320+
row context menu reads it). The menu stops its own clicks, so a
3321+
tap on the card still opens the record; `-my-2` keeps the 44 px
3322+
target from growing the card. */}
3323+
{rowOffersAction(row) && (
3324+
<div data-testid="grid-card-actions" className="-my-2 shrink-0">
3325+
<RowActionMenu {...rowActionMenuProps(row)} maxInlineActions={0} />
3326+
</div>
3327+
)}
33183328
</div>
33193329
);
33203330
};
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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#29150 — on a phone, each list card offers its row's actions
11+
* through one 44 px "⋮" control, and a tap on the card still opens the record.
12+
*
13+
* Driven through the real `ObjectGrid` card face at 390 px against the same
14+
* grid at 1280 px:
15+
*
16+
* 1. Parity: a card's menu lists the row's actions in the set and order the
17+
* desktop row's context menu lists them (the "⋮" items plus the inline
18+
* primary, where the plan places primaries), a `visible` predicate removing
19+
* one from both.
20+
* 2. The control is the row's own trigger, 44 px below `sm`, and choosing an
21+
* item runs it without opening the record; a tap on the card opens it.
22+
* 3. A grid whose rows offer no action draws no control on its cards.
23+
*/
24+
25+
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
26+
import { render, screen, waitFor, cleanup, fireEvent, within, act } from '@testing-library/react';
27+
import userEvent from '@testing-library/user-event';
28+
import '@testing-library/jest-dom';
29+
import React from 'react';
30+
31+
vi.mock('@object-ui/permissions', async (importOriginal) => {
32+
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
33+
return {
34+
...actual,
35+
usePermissions: () => ({ isLoaded: false, checkField: () => true, getObjectApiOperations: () => undefined, can: () => true }),
36+
};
37+
});
38+
39+
import { ObjectGrid } from '../ObjectGrid';
40+
import { registerAllFields } from '@object-ui/fields';
41+
import { ActionProvider, SchemaRendererProvider } from '@object-ui/react';
42+
43+
beforeAll(() => {
44+
registerAllFields();
45+
if (!Element.prototype.scrollIntoView) {
46+
Element.prototype.scrollIntoView = vi.fn() as unknown as Element['scrollIntoView'];
47+
}
48+
});
49+
50+
const ORIGINAL_INNER_WIDTH = window.innerWidth;
51+
function setWidth(px: number) {
52+
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: px });
53+
}
54+
55+
const OBJECT = 'account';
56+
const ROWS = [
57+
{ id: 'a1', name: 'Acme', status: 'draft' },
58+
{ id: 'a2', name: 'Globex', status: 'active' },
59+
];
60+
61+
/** The object's `list_item` actions: one gated per row, one ungated, one primary. */
62+
const ROW_ACTIONS = [
63+
{ name: 'submit_review', label: 'Submit for review', type: 'api', target: '/api/submit', locations: ['list_item'], visible: "record.status == 'draft'" },
64+
{ name: 'archive', label: 'Archive', type: 'api', target: '/api/archive', locations: ['list_item'] },
65+
{ name: 'promote', label: 'Promote', type: 'api', target: '/api/promote', locations: ['list_item'], variant: 'primary' },
66+
];
67+
68+
function renderGrid(opts: {
69+
rowActionDefs?: unknown[];
70+
onRowClick?: (row: any) => void;
71+
onEdit?: ((row: any) => void) | null;
72+
onDelete?: ((row: any) => void) | null;
73+
} = {}) {
74+
const dataSource: any = {
75+
getObjectSchema: async (name: string) => ({
76+
name,
77+
fields: { id: { type: 'text' }, name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } },
78+
}),
79+
};
80+
const schema: any = {
81+
type: 'object-grid',
82+
objectName: OBJECT,
83+
columns: [{ field: 'name', label: 'Name' }, { field: 'status', label: 'Status' }],
84+
data: { provider: 'value', items: ROWS },
85+
rowActionDefs: opts.rowActionDefs ?? ROW_ACTIONS,
86+
};
87+
return render(
88+
<ActionProvider>
89+
<SchemaRendererProvider dataSource={dataSource}>
90+
<ObjectGrid
91+
schema={schema}
92+
dataSource={dataSource}
93+
onEdit={opts.onEdit === null ? undefined : (opts.onEdit ?? vi.fn())}
94+
onDelete={opts.onDelete === null ? undefined : (opts.onDelete ?? vi.fn())}
95+
onRowClick={opts.onRowClick}
96+
/>
97+
</SchemaRendererProvider>
98+
</ActionProvider>,
99+
);
100+
}
101+
102+
const itemIds = (menu: HTMLElement) =>
103+
within(menu).queryAllByRole('menuitem').map((el) => el.getAttribute('data-testid'));
104+
const OPEN_ITEMS = ['row-context-open', 'row-context-open-new-tab'];
105+
106+
/** Desktop: the row's context menu, minus Open / Open in new tab. */
107+
async function desktopRowActions(): Promise<Record<string, (string | null)[]>> {
108+
setWidth(1280);
109+
renderGrid();
110+
// Cells are masked until the object's field types land; read nothing before.
111+
await waitFor(() => expect(screen.getByText('Globex')).toBeInTheDocument());
112+
await waitFor(() => expect(screen.getAllByTestId('row-action-trigger').length).toBe(ROWS.length));
113+
expect(document.querySelector('[data-testid="grid-mobile-card"]'), 'the table, not the cards').toBeNull();
114+
const out: Record<string, (string | null)[]> = {};
115+
for (const row of ROWS) {
116+
const cell = within(screen.getByText(row.name).closest('tr') as HTMLElement).getByText(row.status);
117+
act(() => { fireEvent.contextMenu(cell, { button: 2, clientX: 40, clientY: 60 }); });
118+
const menu = await screen.findByTestId('row-context-menu');
119+
out[row.name] = itemIds(menu).filter((id) => !OPEN_ITEMS.includes(id ?? ''));
120+
await userEvent.keyboard('{Escape}');
121+
await waitFor(() => expect(screen.queryByTestId('row-context-menu')).toBeNull());
122+
}
123+
cleanup();
124+
return out;
125+
}
126+
127+
const cards = () => [...document.querySelectorAll('[data-testid="grid-mobile-card"]')] as HTMLElement[];
128+
const cardOf = (name: string) =>
129+
cards().find((card) => within(card).queryByTestId('grid-card-title')?.textContent === name)!;
130+
131+
async function phoneCards(opts: Parameters<typeof renderGrid>[0] = {}) {
132+
setWidth(390);
133+
renderGrid(opts);
134+
await waitFor(() => expect(cards()).toHaveLength(ROWS.length));
135+
// Titles are masked until the object's field types land.
136+
await waitFor(() => expect(cardOf('Globex')).toBeDefined());
137+
}
138+
139+
/** A card's menu items, read off its real menu, which is closed again. */
140+
async function cardItemIds(name: string): Promise<(string | null)[]> {
141+
await userEvent.click(within(cardOf(name)).getByTestId('row-action-trigger'));
142+
const menu = await screen.findByRole('menu');
143+
const ids = itemIds(menu);
144+
await userEvent.keyboard('{Escape}');
145+
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
146+
return ids;
147+
}
148+
149+
let fetchMock: ReturnType<typeof vi.fn>;
150+
beforeEach(() => {
151+
fetchMock = vi.fn().mockResolvedValue({
152+
ok: true, status: 200, statusText: 'OK',
153+
headers: { get: () => 'application/json' },
154+
json: async () => ({ success: true }),
155+
});
156+
vi.stubGlobal('fetch', fetchMock);
157+
});
158+
afterEach(() => {
159+
cleanup();
160+
vi.unstubAllGlobals();
161+
setWidth(ORIGINAL_INNER_WIDTH);
162+
});
163+
164+
describe('objectstack#29150 · a phone list card offers its row actions', () => {
165+
it('at 390 px each card\'s menu lists the desktop row\'s actions, in the same order', async () => {
166+
const desktop = await desktopRowActions();
167+
// The desktop reference is the full set: Edit, Delete, the primary, then
168+
// the secondaries, the gated one only where its predicate holds.
169+
expect(desktop.Acme).toEqual([
170+
'row-action-builtin-edit',
171+
'row-action-builtin-delete',
172+
'row-action-promote',
173+
'row-action-submit_review',
174+
'row-action-archive',
175+
]);
176+
expect(desktop.Globex).not.toContain('row-action-submit_review');
177+
178+
await phoneCards();
179+
for (const row of ROWS) {
180+
expect(await cardItemIds(row.name), row.name).toEqual(desktop[row.name]);
181+
}
182+
// Every action sits in the one menu: the card draws no inline button.
183+
expect(screen.queryByTestId('row-action-inline-promote')).toBeNull();
184+
});
185+
186+
it('the control is one 44 px trigger per card, at the card\'s right', async () => {
187+
await phoneCards();
188+
for (const row of ROWS) {
189+
const card = cardOf(row.name);
190+
const triggers = within(card).getAllByTestId('row-action-trigger');
191+
expect(triggers).toHaveLength(1);
192+
// 44 px below `sm`, the touch-target floor every phone control keeps.
193+
expect(triggers[0].className).toMatch(/(^|\s)min-h-\[44px\](\s|$)/);
194+
expect(triggers[0].className).toMatch(/(^|\s)min-w-\[44px\](\s|$)/);
195+
expect(card.lastElementChild?.contains(triggers[0])).toBe(true);
196+
}
197+
});
198+
199+
it('choosing an item runs it and does not open the record; a tap on the card opens it', async () => {
200+
const onRowClick = vi.fn();
201+
const onEdit = vi.fn();
202+
await phoneCards({ onRowClick, onEdit });
203+
204+
await userEvent.click(within(cardOf('Acme')).getByTestId('row-action-trigger'));
205+
const menu = await screen.findByRole('menu');
206+
expect(onRowClick).not.toHaveBeenCalled();
207+
await userEvent.click(within(menu).getByTestId('row-action-builtin-edit'));
208+
expect(onEdit).toHaveBeenCalledTimes(1);
209+
expect(onEdit.mock.calls[0][0]).toMatchObject({ id: 'a1' });
210+
expect(onRowClick).not.toHaveBeenCalled();
211+
212+
await userEvent.click(within(cardOf('Globex')).getByTestId('grid-card-title'));
213+
expect(onRowClick).toHaveBeenCalledTimes(1);
214+
expect(onRowClick.mock.calls[0][0]).toMatchObject({ id: 'a2' });
215+
});
216+
217+
it('a card whose row offers no action draws no control', async () => {
218+
await phoneCards({ rowActionDefs: [], onEdit: null, onDelete: null });
219+
expect(screen.queryAllByTestId('row-action-trigger')).toHaveLength(0);
220+
});
221+
});

0 commit comments

Comments
 (0)