Skip to content

Commit f8633c7

Browse files
fix(app-shell): permission grid signals cut columns and pins its Object column and field header (#27909)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_ ## 维护者速读 - 改了什么:权限集编辑器的对象矩阵(Setup 页与 Studio Access 的 Objects 页同一套)在列被截断时右边缘出现渐隐提示;Object 列钉在左边(手机上只显示对象名,留出滚动空间);展开字段行后,字段表头(Field / Read / Edit / When)钉在矩阵表头下面;同一对象里两个字段同名(如 Owner)时,把 API 名作为区分文字显示出来。 - 为什么改:1440 宽时 Bulk 列被截而没有任何提示;手机上横向滚动后看不到每行是哪个对象;展开字段后顶部只剩 C/R/U/D… 的对象表头,压在字段行上,对不上 Read/Edit 列;showcase 的项目和发票对象各有两个都叫「Owner」的字段。 - 风险与回滚:只改前端样式与布局,权限数据、保存内容不变;回滚即 revert 本 PR。 - 先看:`MatrixScrollPane.tsx`(新文件,渐隐提示);`PermissionMatrixEditor.tsx` 的 `STICKY_OBJECT_CELL` / `FIELD_HEAD_CELL` 两个常量及其使用处;`FieldsSubTable` 里的 `sharesLabel`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.gridChrome-27866.test.tsx`,预期 9 条全部通过。 - 需要你做的:无,席位落地。 Fixes #27866 Clause-②: no Metadata-protocol: none ## What changes All in `ui/packages/app-shell/src/views/metadata-admin/` (app-shell is private; it ships inside `@object-ui/console`): - **Overflow cue.** New `MatrixScrollPane.tsx` replaces the bare `overflow-auto` box around the matrix, on the routed page (`PermissionTable`) and the Studio Objects tab (`StudioObjectMatrix`). It reads its own box on scroll and on resize (`readPaneOverflow`) and, while columns are cut on the right, draws an `aria-hidden` fade over the pane's right edge (`perm-matrix-overflow-cue`); the fade goes once the last column is in view. It also stamps `data-scrolled-x` once the pane has scrolled sideways. - **Pinned Object column.** `STICKY_OBJECT_CELL` pins the Object header and every object row's first cell to the pane's left edge over an opaque background, and draws the column's right edge while `data-scrolled-x` is set. Row tints (the routed page's hover, the Studio row's active state) are laid on as a background image so the pinned cell keeps them. On a phone the routed page's Object column is `w-36` (was `w-72`) and the `(api_name)` aside is hidden below `sm`, so the pinned column takes ~178 px of 390 instead of ~332. - **Field header pinned under the matrix header.** `FIELD_HEAD_CELL` pins the field sub-table's header cells to `top-9`, under the matrix header row (now `h-9` on the routed page; the Studio row already was), while the sub-table is in view. Field rows scrolled under the matrix header are labelled by their own Field / Read / Edit / When columns, not by the object columns above. - **Shared labels.** When two of an object's offered fields share a label (case-insensitive), each row draws its `(api_name)` in the foreground, monospace, instead of the muted aside, and carries `data-shared-label`. No i18n key, no export from a published package, no metadata change. ## Tests `PermissionMatrixEditor.gridChrome-27866.test.tsx` (9 tests): `readPaneOverflow` cases, then on both renderings: the cue shows while the stubbed pane is cut and goes at the end (and for a grid that fits), `data-scrolled-x` follows; the Object header and row cells are pinned (CONTROL: a box cell is not); the field header cells pin `top-9` under an `h-9` header row (CONTROL: field rows do not); `owner` / `owner_id` both "Owner" draw their API names in the foreground (CONTROL: `status` keeps the muted aside). <details><summary>Readings</summary> - New file at 02dbcde: Test Files 1 passed, Tests 9 passed (9), lock VERDICT command-exit 0. - Ablation: `PermissionMatrixEditor.tsx` restored to `origin/main` under a trap (markers `STICKY_OBJECT_CELL` and `MatrixScrollPane` counted 0), same file: Tests 8 failed, 1 passed (the pure `readPaneOverflow` case), VERDICT command-exit 1; restored by `git checkout HEAD`, `git diff HEAD` empty, blob hash equal to HEAD. Tests import `src`, so no dist rebuild. - Every test file naming `PermissionMatrixEditor` or `MatrixScrollPane` (48 files): Test Files 48 passed (48), Tests 340 passed (340), VERDICT command-exit 0. - `pnpm --filter @object-ui/app-shell run type-check` (tsc --noEmit and tsconfig.test.json): VERDICT command-exit 0. </details> ## Acceptance notes Re-driven on stock `examples/app-showcase --seed-admin --fresh` (admin, Chromium, Setup, permission set `member_default`) before and after the change, routed page: - [x] **1440 px: cut columns are signalled.** Before: the pane is 1184 px wide, the grid 1304 px, the Bulk header spans 1372–1560 against a pane edge at 1440, no cue. After: the fade is present at scroll 0 and gone at the end (scrollLeft 120), where the Bulk header's right edge equals the pane's (1440). - [x] **Phone: the Object column stays visible.** Before at 390×844: scrolled to the end, the Object column is gone. After: the Object header and first row cell stay at left 0 (header 0–178) with the pane scrolled 914 px. - [x] **No two fields carry the same label.** On `main` both rows already showed their API names as a muted aside (`Owner (owner)`, `Owner (owner_id)` on `showcase_project`, and the same pair on `showcase_invoice`); they now draw the API name as the distinguishing text. The duplicate itself is metadata: the showcase authors a text field `owner` labelled "Owner" beside the platform-injected `owner_id`, also "Owner". That half is outside this card's file surface (`examples/app-showcase`, and the injected label under `packages/**`), named, not done. No two columns share a label. - [x] **With field rows expanded, each header sits over its column.** Before: scrolled into `showcase_project`'s field rows, the only header in view was the object header (C at 584 … Read scope at 1063) while the field Read box sat at 1044. After: the field header pins at top 495, right under the object header (459 + 36), with Read at 1036 over the Read boxes. Not re-driven live: the Studio Objects tab (pinned by the same tests on that rendering). The fade is drawn over the whole pane's right edge, so on the routed page it also lies over the right margin of the advanced facets and Assigned Users below the grid while columns are cut. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYzrSNZwgsCPhWGAbi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d3b0a40 commit f8633c7

4 files changed

Lines changed: 355 additions & 20 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(app-shell): the permission-set grid signals cut columns, keeps its Object column in view, labels expanded field rows by their own header, and tells apart two fields that share a label
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The permission-set grid is wider than its pane at 1440 px and on a phone, on the Setup page and on the Studio Access pillar's Objects tab alike:
12+
13+
- **Cut columns are signalled.** While columns are cut on the right (the Bulk column at 1440 px), the pane's right edge fades; the fade goes once the last column is in view.
14+
- **The Object column stays in view.** The Object column, header and rows, is pinned to the pane's left edge and draws its edge once rows slide under it. On a phone the pinned column shows the object's label alone, so the boxes keep the room to scroll.
15+
- **Expanded field rows keep their own header.** An expanded object's field header (Field / Read / Edit / When) pins under the matrix's header row while the field list is in view, so field rows are no longer read against the object columns above them.
16+
- **Two fields with one label are told apart.** When two of an object's fields share a label (an authored `owner` beside the platform's `owner_id`, both "Owner"), each row draws its API name as the distinguishing text instead of a muted aside.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* The permission matrix's scroll pane (objectstack#27866): the grid's floor is
3+
* wider than the content column at 1440 px, so the pane scrolls sideways. A
4+
* fade on the right edge says columns are cut while any remain past it, and
5+
* `data-scrolled-x` lets the pinned first column draw its edge once rows slide
6+
* under it.
7+
*/
8+
import * as React from 'react';
9+
import { cn } from '@object-ui/components';
10+
11+
/** Where the pane's horizontal scroll sits: content cut on the right, scrolled off the left. */
12+
export interface PaneOverflow {
13+
right: boolean;
14+
left: boolean;
15+
}
16+
17+
/** Read off the pane's box; a sub-pixel remainder is not a cut column. */
18+
export function readPaneOverflow(el: Pick<HTMLElement, 'scrollLeft' | 'scrollWidth' | 'clientWidth'>): PaneOverflow {
19+
return {
20+
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
21+
left: el.scrollLeft > 0,
22+
};
23+
}
24+
25+
export function MatrixScrollPane({
26+
className,
27+
testId,
28+
children,
29+
}: {
30+
/** Classes of the pane's outer box (its place in the page's flex column). */
31+
className?: string;
32+
testId?: string;
33+
children: React.ReactNode;
34+
}) {
35+
const ref = React.useRef<HTMLDivElement>(null);
36+
const [overflow, setOverflow] = React.useState<PaneOverflow>({ right: false, left: false });
37+
const measure = React.useCallback(() => {
38+
const el = ref.current;
39+
if (!el) return;
40+
const next = readPaneOverflow(el);
41+
setOverflow((prev) => (prev.right === next.right && prev.left === next.left ? prev : next));
42+
}, []);
43+
React.useLayoutEffect(() => {
44+
measure();
45+
const el = ref.current;
46+
if (!el || typeof ResizeObserver === 'undefined') return;
47+
// The pane and the grid both resize: the window, and a field list opening.
48+
const ro = new ResizeObserver(measure);
49+
ro.observe(el);
50+
if (el.firstElementChild) ro.observe(el.firstElementChild);
51+
return () => ro.disconnect();
52+
}, [measure]);
53+
return (
54+
<div className={cn('relative flex min-h-0 flex-col', className)}>
55+
<div
56+
ref={ref}
57+
onScroll={measure}
58+
data-testid={testId}
59+
data-scrolled-x={overflow.left || undefined}
60+
className="group/matrix min-h-0 flex-1 overflow-auto"
61+
>
62+
{children}
63+
</div>
64+
{overflow.right && (
65+
<div
66+
aria-hidden="true"
67+
data-testid="perm-matrix-overflow-cue"
68+
className="pointer-events-none absolute inset-y-0 right-0 z-20 w-10 bg-gradient-to-l from-background via-background/70 to-transparent"
69+
/>
70+
)}
71+
</div>
72+
);
73+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27866 — the permission-set grid stays readable when it is wider
5+
* than its pane (1440 px desktop, 390 px phone), on both renderings:
6+
*
7+
* - while columns are cut on the right, a fade on the pane's right edge says
8+
* so (`MatrixScrollPane`), and it goes once the last column is in view;
9+
* - the Object column, header and rows, is pinned to the pane's left edge;
10+
* - an expanded object's field header pins under the matrix's header row, so
11+
* field rows are labelled by their own columns;
12+
* - two fields sharing a label draw their API names as the distinguishing text.
13+
*
14+
* jsdom lays nothing out: the pane's box is stubbed where the cue is read, and
15+
* the rest pins the classes the browser reads; it is no screenshot.
16+
*/
17+
18+
import '@testing-library/jest-dom/vitest';
19+
import { afterEach, describe, expect, it, vi } from 'vitest';
20+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
21+
import { MemoryRouter } from 'react-router-dom';
22+
import { t } from './i18n';
23+
import { readPaneOverflow } from './MatrixScrollPane';
24+
25+
type FakeClient = Record<string, (...args: never[]) => unknown>;
26+
27+
let clientImpl: FakeClient;
28+
29+
vi.mock('./useMetadata', () => ({
30+
useMetadataClient: () => clientImpl,
31+
useMetadataTypes: () => ({
32+
loading: false,
33+
error: null,
34+
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
35+
}),
36+
}));
37+
38+
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
39+
40+
import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
41+
42+
afterEach(cleanup);
43+
44+
// An authored `owner` beside the platform's `owner_id`, both labelled "Owner".
45+
const FIELDS = [
46+
{ name: 'name', label: 'Project Name' },
47+
{ name: 'owner', label: 'Owner' },
48+
{ name: 'owner_id', label: 'Owner', system: true },
49+
{ name: 'status', label: 'Status' },
50+
];
51+
52+
function makeClient(): FakeClient {
53+
return {
54+
layered: async () => ({
55+
effective: {
56+
name: 'project_editor',
57+
label: 'Project editor',
58+
objects: { a_project: { allowRead: true }, b_task: { allowRead: true } },
59+
fields: {},
60+
},
61+
code: null,
62+
overlay: null,
63+
overlayScope: null,
64+
}),
65+
getDraft: async () => null,
66+
withPreviewDrafts() {
67+
return this;
68+
},
69+
list: async (type: string) =>
70+
type === 'object' ? [{ item: { name: 'a_project', label: 'Project' } }, { item: { name: 'b_task', label: 'Task' } }] : [],
71+
get: async (type: string) => (type === 'object' ? { fields: FIELDS } : null),
72+
save: async (_type: string, _name: string, payload: Record<string, unknown>) => payload,
73+
};
74+
}
75+
76+
async function renderPage(studio = false) {
77+
clientImpl = makeClient();
78+
render(
79+
<MemoryRouter>
80+
<PermissionMatrixEditPage
81+
type="permission"
82+
name="project_editor"
83+
packageId="app.a"
84+
studio={studio ? { tab: 'objects', onTabChange: () => {} } : undefined}
85+
/>
86+
</MemoryRouter>,
87+
);
88+
await screen.findByRole('button', { name: objectButton('a_project') }, { timeout: 5000 });
89+
}
90+
91+
/** An object row's expand button: its label, then its API name. */
92+
const objectButton = (name: string) => new RegExp(`^${name === 'a_project' ? 'Project' : 'Task'}\\W*${name}\\W*$`);
93+
94+
async function expand() {
95+
fireEvent.click(screen.getByRole('button', { name: objectButton('a_project') }));
96+
await screen.findByLabelText('a_project.owner readable');
97+
}
98+
99+
/** Gives the pane a box, then lets it re-read it the way a scroll does. */
100+
function layOut(pane: HTMLElement, box: { scrollLeft: number; scrollWidth: number; clientWidth: number }) {
101+
Object.defineProperty(pane, 'scrollWidth', { configurable: true, value: box.scrollWidth });
102+
Object.defineProperty(pane, 'clientWidth', { configurable: true, value: box.clientWidth });
103+
pane.scrollLeft = box.scrollLeft;
104+
act(() => {
105+
fireEvent.scroll(pane);
106+
});
107+
}
108+
109+
const PINNED_LEFT = ['sticky', 'left-0', 'bg-background'];
110+
111+
describe('readPaneOverflow (objectstack#27866)', () => {
112+
it('reads a cut right edge and a scrolled-off left edge', () => {
113+
expect(readPaneOverflow({ scrollLeft: 0, scrollWidth: 1304, clientWidth: 1184 })).toEqual({ right: true, left: false });
114+
expect(readPaneOverflow({ scrollLeft: 120, scrollWidth: 1304, clientWidth: 1184 })).toEqual({ right: false, left: true });
115+
expect(readPaneOverflow({ scrollLeft: 60, scrollWidth: 1304, clientWidth: 1184 })).toEqual({ right: true, left: true });
116+
// A grid that fits, and a sub-pixel remainder, cut nothing.
117+
expect(readPaneOverflow({ scrollLeft: 0, scrollWidth: 1184, clientWidth: 1184 })).toEqual({ right: false, left: false });
118+
expect(readPaneOverflow({ scrollLeft: 0, scrollWidth: 1184.5, clientWidth: 1184 })).toEqual({ right: false, left: false });
119+
});
120+
});
121+
122+
describe.each([
123+
['the routed Setup page', false],
124+
['the Studio Objects tab', true],
125+
])('the permission grid on %s (objectstack#27866)', (_where, studio) => {
126+
it('fades the right edge while columns are cut, and not once the last one is in view', async () => {
127+
await renderPage(studio);
128+
const pane = screen.getByTestId('perm-matrix-pane');
129+
expect(pane).toContainElement(screen.getAllByRole('table')[0]);
130+
131+
layOut(pane, { scrollLeft: 0, scrollWidth: 1304, clientWidth: 1184 });
132+
expect(screen.getByTestId('perm-matrix-overflow-cue')).toHaveAttribute('aria-hidden', 'true');
133+
expect(pane).not.toHaveAttribute('data-scrolled-x');
134+
135+
layOut(pane, { scrollLeft: 120, scrollWidth: 1304, clientWidth: 1184 });
136+
expect(screen.queryByTestId('perm-matrix-overflow-cue')).toBeNull();
137+
// Rows now slide under the pinned column, which draws its edge.
138+
expect(pane).toHaveAttribute('data-scrolled-x', 'true');
139+
140+
// CONTROL: a grid that fits its pane shows no cue.
141+
layOut(pane, { scrollLeft: 0, scrollWidth: 1600, clientWidth: 1600 });
142+
expect(screen.queryByTestId('perm-matrix-overflow-cue')).toBeNull();
143+
});
144+
145+
it('pins the Object column, header and every row, to the left edge', async () => {
146+
await renderPage(studio);
147+
const header = screen.getByRole('columnheader', { name: t('perm.col.object', 'en') });
148+
expect(header).toHaveClass(...(studio ? ['sticky', 'left-0', 'bg-sidebar'] : PINNED_LEFT));
149+
for (const name of ['a_project', 'b_task']) {
150+
const cell = screen.getByRole('button', { name: objectButton(name) }).closest('td');
151+
expect(cell).toHaveClass(...PINNED_LEFT);
152+
}
153+
// CONTROL: the boxes scroll under it.
154+
expect(screen.getByLabelText(/^a_project Create/).closest('td')).not.toHaveClass('sticky');
155+
});
156+
157+
it("pins the field header under the matrix's header row once an object is expanded", async () => {
158+
await renderPage(studio);
159+
await expand();
160+
// The matrix's header row is the h-9 the field header pins beneath.
161+
const objectHeader = screen.getByRole('columnheader', { name: t('perm.col.object', 'en') });
162+
expect(objectHeader.closest('tr')).toHaveClass('h-9');
163+
const fieldHead = screen.getByTestId('perm-field-head-a_project');
164+
const cells = Array.from(fieldHead.querySelectorAll('th'));
165+
expect(cells.map((c) => c.textContent)).toEqual([
166+
t('perm.field.col.name', 'en'),
167+
t('perm.field.read', 'en'),
168+
t('perm.field.edit', 'en'),
169+
t('perm.field.when', 'en'),
170+
]);
171+
for (const c of cells) expect(c).toHaveClass('sticky', 'top-9', 'bg-background');
172+
// The name header is pinned both ways and stacks over the pinned name cells.
173+
expect(cells[0]).toHaveClass('left-0', 'z-[3]');
174+
// CONTROL: field rows are not pinned to the top.
175+
expect(screen.getByLabelText('a_project.owner readable').closest('td')).not.toHaveClass('top-9');
176+
});
177+
178+
it('draws the API names of two fields that share a label as their distinguishing text', async () => {
179+
await renderPage(studio);
180+
await expand();
181+
for (const name of ['owner', 'owner_id']) {
182+
const cell = screen.getByTestId(`perm-field-name-a_project-${name}`);
183+
expect(cell).toHaveAttribute('data-shared-label', 'true');
184+
expect(cell).toHaveTextContent(`Owner(${name})`);
185+
expect(cell.querySelector('span')).toHaveClass('text-foreground');
186+
expect(cell.querySelector('span')).not.toHaveClass('text-muted-foreground');
187+
}
188+
// CONTROL: a field with a label of its own keeps the muted aside.
189+
const status = screen.getByTestId('perm-field-name-a_project-status');
190+
expect(status).not.toHaveAttribute('data-shared-label');
191+
expect(status.querySelector('span')).toHaveClass('text-muted-foreground');
192+
});
193+
});

0 commit comments

Comments
 (0)