Skip to content

Commit 2fe4a74

Browse files
feat(app-shell): the permission-set editor shows the Export column, and Assigned Users no longer covers the grid (#26955)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb3vYVoJUcUBp4oSDWTfR2Vn)_ Fixes #26885 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:权限集编辑器(Setup 页与 Studio「访问」的对象页)增加「导出」列(E,`allowExport`),位置在删除与转移之间,和岗位页摘要的顺序一致,可以看到并勾选。Setup 页的「已分配用户」面板从网格下方固定的 35% 高条带,移到网格自己的滚动区里、排在网格和高级设置之后;手机宽度下整页一起滚动。 - **为什么改**:原来授予导出的权限集和不授予的看起来一样,管理员无法在授予它的权限集上看到导出权限;笔记本上「已分配用户」面板挤得网格只剩约 5 行,手机上一行都看不到。 - **风险与回滚**:低。只改前端编辑器的列表与布局,服务端、spec 不动,存储的数据不变。批量按钮 R / CRUD / All 都不碰导出(导出是需要单独授予的高权限项,All 的提示里写明),None 仍清空整行。回滚即还原本 PR。 - **先看**:`ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx` 的 `getObjectActions`(新增 `optIn` 的 Export 行)、`bulkSetObject`(`bulkKeys` 排除导出)、页面主体(`perm-assignments` 移进网格滚动区,外层 `md:h-full md:overflow-hidden`);`PermissionMatrixEditor.exportColumn-26885.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.exportColumn-26885.test.tsx`,预期 9 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **Export is a matrix column.** `getObjectActions` lists `allowExport` (`E`, "Export records"; zh 导出记录) between Delete and Transfer, the order `previews/PermissionPreview`'s `CAPS` uses for the position summary. Both renderings read that list, so the routed Setup page's table and the Studio Access pillar's Objects tab both gain the header, the per-row checkbox (toggled through `updateObjectPerm`, like Transfer) and the legend entry (`engine.studio.access.legend.export`). `ObjectPerm` in `permission-slice.ts` declares the key (one line), so `ObjectPermFlag` covers it. - **The bulk buttons leave Export as it is.** The entry carries `optIn: true`; `bulkSetObject` resets and grants only the other columns (`bulkKeys`). So R, CRUD and All neither set nor clear Export, which is what they did before this change too, when the key rode through unmodelled. "All" gets a tooltip saying so (`perm.bulk.allTip`). "None" still replaces the row with `{}`, unchanged. Reason for the choice: `allowExport` is the opt-in, high-privilege export axis (`packages/spec/src/security/high-privilege.ts`, the ADR-0090 D5 list), whose point is that export is granted deliberately; a button labelled "All" quietly granting bulk egress would undo that. The bulk-arm pins in `PermissionMatrixEditor.bulkMergeKeys.test.tsx` hold unchanged; only their comments are updated. - **Assigned Users scrolls with the grid.** The Setup page's `perm-assignments` panel moves from a `shrink-0 max-h-[35vh]` strip under the matrix into the matrix's own scroll box, after the grid and the advanced facets. - **On a phone the page scrolls as one.** Below `md` the editor's column takes its content's height (`md:h-full md:overflow-hidden`), so `PageShell`'s body scrolls the chrome and the grid together. From `md` up nothing changes. The filter bar wraps (`flex-wrap`, input `w-full sm:w-72`). - The grid's minimum width grows by the one column (1248px to 1304px) on both renderings. - Four new `studio` namespace rows in `@object-ui/i18n`, `en` and `zh`. One root changeset: `@object-ui/app-shell` patch, `@object-ui/i18n` patch. - No `packages/spec/**`, no server change, no new metadata key. ## Tests `PermissionMatrixEditor.exportColumn-26885.test.tsx`, nine cases on the real `PermissionMatrixEditPage`, asserting the saved payload: - the `E` header sits between `D` and `Tr`, titled "Export records"; - a stored grant shows ticked, and unticking saves `allowExport: false`; ticking saves `allowExport: true` and nothing else; - the Studio Objects tab has the column and its legend entry, and its checkbox saves the grant; - "All" grants the seven other columns and not Export, and its tooltip says so; "R", "CRUD" and "All" each keep a granted Export ticked and saved; - in the Setup scope, Assigned Users is inside the grid's scroll box and after the table. `PermissionMatrixEditor.retiredLifecycleKeys.test.tsx`: the live column set now includes `E` (the pin's purpose is the exact set). ## Acceptance notes - **Measured in a browser** (Chromium, `pnpm dev:crm -- --fresh`, Setup → Permission Sets → `crm_sales_user`, 83 objects, the built Console from this branch). At 1440×900: the grid's scroll box is 441px tall with 9 object rows fully on screen, the panel sits after the grid at the end of that scroll, no horizontal page overflow. At 390×844: once the page is scrolled to the grid, 12 rows are on screen (before the phone change, the grid's own box was 121px tall with 2 rows). "Only granted" is fully in view at both widths. The before reading (about 5 rows at 1440×900, none at 390×844) is the filer's, from the card's screenshots; I did not rebuild main to re-measure it. Screenshots are in the project files under `issue-26885/`. - `crm_sales_user` is a code-package set, so it is read-only in the browser; toggling Export is covered by the tests above, not the browser pass. - On a phone the table header is not sticky while the page scrolls (it is sticky inside the grid's own box from `md` up). - At 1440×900 the grid is still wider than the content area with the Setup sidebar open (minimum 1304px), so Bulk and the scope pickers sit behind the grid's horizontal scroll, as before. ## Verification All at head `dafbeddd`, in a worktree off `origin/main` `8074feb6`, unless a line names another head. <details><summary>New tests, ablations, type-check and nearby suites</summary> - `pnpm --dir ui exec vitest run` on `PermissionMatrixEditor.exportColumn-26885`, `.bulkMergeKeys`, `.retiredLifecycleKeys` and `.setupCatalog-7611` under `scripts/pm/os-verify-lock.sh`: `Test Files 4 passed (4)`, `Tests 26 passed (26)`. - Ablation 1 (`node scripts/ablation-replace.mjs`, anchor `const bulkKeys = OBJECT_ACTIONS.filter((a) => !a.optIn).map((a) => a.key);` replaced by `const bulkKeys = OBJECT_ACTIONS.map((a) => a.key);`, anchor x1 to x0): `Tests 3 failed | 6 passed (9)`, the three bulk cases ("All" grants every other column but not Export, "R" and "CRUD" keep a granted Export). Restored, blob equal to HEAD (`b1f94509ca8b`). The tests import the editor from source, so no build sits between mutation and run. - Pre-fix leg for the layout case: the new test file run against `origin/main`'s `PermissionMatrixEditor.tsx` (written to disk, restored from HEAD under a trap, blob checked): "renders in the matrix's own scroll, after the grid" fails (`expected false to be true`). - `pnpm --filter @object-ui/app-shell run type-check && pnpm --filter @object-ui/i18n run type-check` under the lock: `VERDICT command-exit 0` (app-shell's covers the test files). - Narrowed suite at `3c942530` (the later commit touches only `ui/scripts/`): every app-shell test file naming `PermissionMatrixEdit`, `permission-slice`, a `perm.action` / `perm.bulk` / `engine.studio.access` key or `AccessPillar` (52 files), plus all of `packages/i18n/`: `Test Files 141 passed (141)`, `Tests 1756 passed | 13 skipped (1769)`. CI's Test UI runs the rest of app-shell. - `pnpm run check:i18n-designer-parity` (from `ui/`): "Every en row has a zh row, and every shared row carries the same placeholders." `pnpm check:new-line-citations` (from `ui/`): `0 new citation(s)`. </details> <details><summary>Browser measurement</summary> Chromium (Playwright), `pnpm dev:crm -- --fresh` on a private port, the Console built from this branch (`turbo run build` then `pnpm console:build`), signed in as the seeded admin, `/_console/apps/setup/metadata/permission/crm_sales_user?scope=environment`: - 1440×900: headers `Object C R U D E Tr VA MA Read scope Write scope Bulk`; the grid's scroll box is 441px tall from y=459, 9 object rows fully on screen; `perm-assignments` inside that box, after the grid; "Only granted" in view; no horizontal page overflow. - 390×844: same headers; the column is page-height and the page body scrolls it; with the grid scrolled to the top of the body (661px), 12 object rows fully on screen; "Only granted" in view (x 76–148); no horizontal page overflow. Before the phone change, at `9ee0f570`, the grid's own box was 121px tall with 2 rows. </details> <details><summary>Derived dispatch gates (34/34)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands at `dafbeddd`; every one exited 0, the PR-body gates (`check-closing-keyword-parity`, `check-adr-0087-registration`, `check-changeset-no-major`) against this body. - `pnpm --dir ui exec vitest run scripts/` under the lock: `Test Files 178 passed | 2 skipped (180)`, `Tests 5065 passed | 2 skipped (5067)`. Its first run at `3c942530` was red in one case: `check-designer-field-key-parity.test.ts` "stays EMIT-SIDE" named `allowExport` as the key `ObjectPerm` omits. That is no longer true, so the case now names `allowRestore` (still accepted by the spec, still omitted), and the gate's header comment lists the current omissions. - `pnpm check:type-check-debt` under the lock: `0 raw tsc error(s) total, none above its recorded number`. - `dispatch-gates --ran`: `34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED`. - `pnpm check:nul-bytes`: OK. </details> Dogfood battery: not run, no `!` in the title and no `(narrowing)` arm. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jURizai1Jspt8sbMXk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 23ee3ae commit 2fe4a74

10 files changed

Lines changed: 284 additions & 32 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(app-shell): the permission-set editor shows the Export column, and Assigned Users no longer covers the grid
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The permission-set editor listed Create, Read, Edit, Delete, Transfer, View All and Modify All, but not Export, so a set that grants `allowExport` looked the same as one that does not. On the Setup page, the Assigned Users panel sat in a strip pinned under the grid, up to 35% of the window tall, so a laptop showed about five object rows and a phone none.
12+
13+
- **Export is a column** (`E`, "Export records"; Chinese: 导出记录) between Delete and Transfer, the order the position summary uses, on the Setup page and in Studio's Access Objects tab. It shows a stored grant and toggles it like Transfer.
14+
- **The bulk buttons leave Export as it is.** Export is an opt-in, high-privilege grant, so R, CRUD and All neither set nor clear it, and the All button's tooltip says so. None still empties the whole row.
15+
- **Assigned Users scrolls with the grid**, after it and the advanced facets, so the grid keeps the page's height. Measured on the Setup page at 1440×900: 9 object rows on screen.
16+
- **On a phone the page scrolls as one.** Below the `md` width the chrome above the grid (header, notices, capabilities, filter, legend) is taller than the screen, so the page body scrolls everything together instead of leaving the grid a box about 120px tall; at 390×844, 12 rows fit once the grid is scrolled up. The filter bar wraps there too.
17+
- No key is declared anywhere, and nothing a set already stores changes.

‎ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.bulkMergeKeys.test.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* objectui#6605 — the bulk buttons (R / CRUD / All / None) and the keys the
5-
* matrix does not author.
4+
* objectui#6605 — the bulk buttons (R / CRUD / All / None) and the keys they
5+
* do not author.
66
*
77
* `bulkSetObject` used to REPLACE the object's permission row. Keys outside
88
* the `OBJECT_ACTIONS` column list — `allowExport`, and the ADR-0057
@@ -22,6 +22,12 @@
2222
* `org` either way. It still merges: `allowExport` and every other key the
2323
* matrix does not author rides through.
2424
*
25+
* ## Export has a column, and still no bulk button — objectstack#26885
26+
*
27+
* Export (`allowExport`) is a matrix column now, but an opt-in one: no
28+
* granting arm sets or clears it, "All" included, so the pins below hold as
29+
* written. Its own pins live in `PermissionMatrixEditor.exportColumn-26885`.
30+
*
2531
* Every pin here asserts the SAVED payload, never editor state. That is the
2632
* card's own argument for why the defect persisted: both save doors carry the
2733
* row as-is — the environment door writes the whole record, and at package
@@ -47,7 +53,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
4753
import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react';
4854
import { MemoryRouter } from 'react-router-dom';
4955

50-
/** Every object-permission key the matrix authors, in column order. */
56+
/** Every object-permission key the bulk buttons author, in column order (all but Export). */
5157
const MATRIX_KEYS = [
5258
'allowCreate',
5359
'allowRead',
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26885 — the permission-set editor shows every axis the set
5+
* carries, Export included, and the Setup page's Assigned Users panel no
6+
* longer takes the grid's height.
7+
*
8+
* - Export (`allowExport`) is a column on both renderings — the routed page's
9+
* table and the Studio Access pillar's Objects tab — found by its header
10+
* and toggled like Transfer; the saved payload carries the bit.
11+
* - Export is an opt-in, high-privilege grant (`@objectstack/spec`
12+
* `security/high-privilege.ts`), so the granting bulk buttons leave it as
13+
* it is: "All" does not grant it, and none of them clears it. "None" still
14+
* empties the whole row (pinned in `PermissionMatrixEditor.bulkMergeKeys`).
15+
* - Assigned Users renders in the matrix's own scroll, after the grid, not
16+
* as a strip pinned under it.
17+
*
18+
* Every grant assertion reads the SAVED payload, the only thing either save
19+
* door persists.
20+
*/
21+
22+
import { describe, it, expect, vi, afterEach } from 'vitest';
23+
import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react';
24+
import { MemoryRouter } from 'react-router-dom';
25+
26+
interface FakeServer {
27+
set: Record<string, any>;
28+
saved: Record<string, any> | null;
29+
}
30+
31+
function makeClient(server: FakeServer) {
32+
return {
33+
layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }),
34+
getDraft: async () => null,
35+
list: async (type: string) =>
36+
type === 'object' ? Object.keys(server.set.objects).map((name) => ({ item: { name } })) : [],
37+
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
38+
save: async (_t: string, _n: string, payload: Record<string, any>) => {
39+
server.saved = payload;
40+
return payload;
41+
},
42+
} as any;
43+
}
44+
45+
let clientImpl: any;
46+
47+
vi.mock('./useMetadata', () => ({
48+
useMetadataClient: () => clientImpl,
49+
useMetadataTypes: () => ({
50+
loading: false,
51+
error: null,
52+
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
53+
}),
54+
}));
55+
vi.mock('./AssignedUsersSection', () => ({
56+
AssignedUsersSection: () => <div data-testid="assigned-users-stub" />,
57+
}));
58+
vi.mock('@object-ui/fields', async (importOriginal) => ({
59+
...(await importOriginal<typeof import('@object-ui/fields')>()),
60+
CapabilityMultiSelectField: () => <div data-testid="cap-picker" />,
61+
parseCapabilityNames: (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : []),
62+
}));
63+
64+
import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
65+
66+
afterEach(cleanup);
67+
68+
async function renderSet(
69+
objects: Record<string, unknown>,
70+
opts: { search?: string; studio?: boolean } = {},
71+
): Promise<FakeServer> {
72+
const server: FakeServer = { set: { name: 'sales_rep', label: 'Sales Rep', objects, fields: {} }, saved: null };
73+
clientImpl = makeClient(server);
74+
render(
75+
<MemoryRouter initialEntries={[`/apps/setup/metadata/permission/sales_rep${opts.search ?? ''}`]}>
76+
<PermissionMatrixEditPage
77+
type="permission"
78+
name="sales_rep"
79+
studio={opts.studio ? { tab: 'objects', onTabChange: () => {} } : undefined}
80+
/>
81+
</MemoryRouter>,
82+
);
83+
await screen.findByText('crm_case');
84+
return server;
85+
}
86+
87+
/** Click Save and return the payload the client was handed. */
88+
async function save(server: FakeServer) {
89+
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
90+
await waitFor(() => expect(server.saved).not.toBeNull());
91+
return server.saved!;
92+
}
93+
94+
function row(objectName: string) {
95+
const found = screen.getAllByRole('row').find((r) => within(r).queryByText(objectName) != null);
96+
expect(found, `row for ${objectName}`).toBeTruthy();
97+
return found!;
98+
}
99+
100+
describe('the Export column (objectstack#26885)', () => {
101+
it('has a header, between Delete and Transfer, as the position summary orders it', async () => {
102+
await renderSet({ crm_case: { allowRead: true } });
103+
const headers = screen.getAllByRole('columnheader').map((th) => th.textContent?.trim() ?? '');
104+
const e = headers.indexOf('E');
105+
expect(e).toBeGreaterThan(0);
106+
expect(headers[e - 1]).toBe('D');
107+
expect(headers[e + 1]).toBe('Tr');
108+
expect(screen.getAllByRole('columnheader')[e].getAttribute('title')).toBe('Export records');
109+
});
110+
111+
it('shows a stored grant, and unticking it saves allowExport: false', async () => {
112+
const server = await renderSet({ crm_case: { allowRead: true, allowExport: true } });
113+
const box = screen.getByLabelText('crm_case Export records');
114+
expect(box.getAttribute('data-state')).toBe('checked');
115+
116+
fireEvent.click(box);
117+
const payload = await save(server);
118+
expect(payload.objects.crm_case).toEqual({ allowRead: true, allowExport: false });
119+
});
120+
121+
it('ticking it saves allowExport: true and nothing else', async () => {
122+
const server = await renderSet({ crm_case: { allowRead: true } });
123+
const box = screen.getByLabelText('crm_case Export records');
124+
expect(box.getAttribute('data-state')).toBe('unchecked');
125+
126+
fireEvent.click(box);
127+
const payload = await save(server);
128+
expect(payload.objects.crm_case).toEqual({ allowRead: true, allowExport: true });
129+
});
130+
131+
it('the Studio Objects tab has the column too, with its legend entry', async () => {
132+
const server = await renderSet({ crm_case: { allowRead: true } }, { studio: true });
133+
const matrix = screen.getByTestId('perm-studio-matrix');
134+
const headers = within(matrix).getAllByRole('columnheader').map((th) => th.textContent?.trim() ?? '');
135+
expect(headers).toContain('E');
136+
expect(within(screen.getByTestId('perm-objects-toolbar')).getByText('Export')).toBeTruthy();
137+
138+
fireEvent.click(within(matrix).getByLabelText('crm_case Export records'));
139+
const payload = await save(server);
140+
expect(payload.objects.crm_case.allowExport).toBe(true);
141+
});
142+
});
143+
144+
describe('the bulk buttons leave Export as it is (objectstack#26885)', () => {
145+
const bulk = (label: string) => within(row('crm_case')).getByRole('button', { name: new RegExp(`^${label}$`) });
146+
147+
it('"All" grants every other column but not Export, and says so on the button', async () => {
148+
const server = await renderSet({ crm_case: { allowRead: true } });
149+
expect(bulk('All').getAttribute('title')).toMatch(/except Export/);
150+
151+
fireEvent.click(bulk('All'));
152+
const payload = await save(server);
153+
expect(payload.objects.crm_case).toEqual({
154+
allowCreate: true,
155+
allowRead: true,
156+
allowEdit: true,
157+
allowDelete: true,
158+
allowTransfer: true,
159+
viewAllRecords: true,
160+
modifyAllRecords: true,
161+
});
162+
});
163+
164+
for (const label of ['R', 'CRUD', 'All']) {
165+
it(`"${label}" keeps a granted Export ticked`, async () => {
166+
const server = await renderSet({ crm_case: { allowRead: true, allowExport: true } });
167+
fireEvent.click(bulk(label));
168+
expect(screen.getByLabelText('crm_case Export records').getAttribute('data-state')).toBe('checked');
169+
const payload = await save(server);
170+
expect(payload.objects.crm_case.allowExport).toBe(true);
171+
});
172+
}
173+
});
174+
175+
describe('Assigned Users does not take the grid’s height (objectstack#26885)', () => {
176+
it('renders in the matrix’s own scroll, after the grid', async () => {
177+
await renderSet({ crm_case: { allowRead: true } }, { search: '?scope=environment' });
178+
const panel = screen.getByTestId('perm-assignments');
179+
const table = screen.getByRole('table');
180+
// The table's parent is the matrix's scroll box: the panel scrolls with
181+
// the grid instead of standing beside it as a fixed-height strip.
182+
const scroller = table.parentElement!;
183+
expect(scroller.className).toMatch(/\boverflow-auto\b/);
184+
expect(scroller.contains(panel)).toBe(true);
185+
expect(table.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
186+
// Control: the panel is the Setup scope's — the stub really mounted in it.
187+
expect(within(panel).getByTestId('assigned-users-stub')).toBeTruthy();
188+
});
189+
});

‎ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.retiredLifecycleKeys.test.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
3939
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
4040
import { MemoryRouter } from 'react-router-dom';
4141

42-
/** The object-permission keys the matrix may author, in column order. */
43-
const LIVE_COLUMN_SHORTS = ['C', 'R', 'U', 'D', 'Tr', 'VA', 'MA'];
42+
/** The object-permission keys the matrix may author, in column order (Export since objectstack#26885). */
43+
const LIVE_COLUMN_SHORTS = ['C', 'R', 'U', 'D', 'E', 'Tr', 'VA', 'MA'];
4444

4545
let clientImpl: any;
4646
let saved: Record<string, any> | null = null;
@@ -130,6 +130,8 @@ describe('PermissionMatrixEditor · retired lifecycle keys (objectui#6595)', ()
130130
expect(screen.getByLabelText('a_account Transfer ownership')).toBeTruthy();
131131
});
132132

133+
// Export is a live column that "All" leaves alone (objectstack#26885: an
134+
// opt-in grant, made only by its own checkbox), so it is absent here too.
133135
it('"Grant all" writes exactly the live keys — the retired pair cannot ride along', async () => {
134136
await renderSet({ a_account: {} });
135137

0 commit comments

Comments
 (0)