Skip to content

Commit d121cb3

Browse files
os-zhuangclaude
andauthored
feat(access): Studio permission matrix — field-level bulk + filter for wide objects (#2600 B4) (#2651)
Expanding an object with many fields (the field_zoo pain) meant toggling two checkboxes per field, one at a time — the object row had read/all/none shortcuts but the field sub-table had none. - FieldsSubTable gains a field filter (name/label, shown once an object has more than ~6 fields) and Read-all / Write-all / Clear shortcuts that act over the currently VISIBLE (filtered) fields, so you can narrow then bulk-apply. - New parent `bulkSetFields(object, action, fieldNames)`: `readable` sets read only, `writable` grants R+W, `clear` drops the overrides back to default. - A visible/total count keeps the filter honest. Read-only packages still hide the bulk actions (writable-gated); the per-field checkboxes and their aria-labels are unchanged. Adds PermissionMatrixEditor.fieldBulk.test.tsx (write-all, filter-scoped bulk, clear-to-default). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a6c4f4d commit d121cb3

3 files changed

Lines changed: 245 additions & 40 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#2600 B4 — field-level bulk + filter. Expanding a wide object used to
5+
* mean toggling two checkboxes per field, one field at a time. The sub-table
6+
* now carries a field filter and Read-all / Write-all / Clear shortcuts that
7+
* act over the VISIBLE (filtered) fields — mirroring the per-object row.
8+
*/
9+
10+
import '@testing-library/jest-dom/vitest';
11+
import { afterEach, describe, expect, it, vi } from 'vitest';
12+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
13+
import { MemoryRouter } from 'react-router-dom';
14+
15+
let clientImpl: any;
16+
17+
vi.mock('./useMetadata', () => ({
18+
useMetadataClient: () => clientImpl,
19+
useMetadataTypes: () => ({
20+
loading: false,
21+
error: null,
22+
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
23+
}),
24+
}));
25+
26+
import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
27+
28+
afterEach(cleanup);
29+
30+
// Eight fields → wide enough that the filter shows (threshold is > 6).
31+
const FIELDS = ['amount', 'email', 'name', 'owner', 'phone', 'region', 'revenue', 'status'];
32+
33+
function makeClient() {
34+
return {
35+
layered: async () => ({
36+
effective: { name: 'sales_perms', label: 'Sales', objects: { a_account: { allowRead: true } }, fields: {} },
37+
code: null,
38+
overlay: null,
39+
overlayScope: null,
40+
}),
41+
getDraft: async () => null,
42+
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
43+
get: async (type: string) =>
44+
type === 'object' ? { fields: FIELDS.map((name) => ({ name })) } : null,
45+
save: async (_t: string, _n: string, payload: Record<string, unknown>) => payload,
46+
} as any;
47+
}
48+
49+
async function renderExpanded() {
50+
clientImpl = makeClient();
51+
render(
52+
<MemoryRouter>
53+
<PermissionMatrixEditPage type="permission" name="sales_perms" packageId="app.a" />
54+
</MemoryRouter>,
55+
);
56+
// Expand the object row, then wait for its fields to load.
57+
fireEvent.click(await screen.findByRole('button', { name: /a_account/ }));
58+
await screen.findByLabelText('a_account.email readable');
59+
}
60+
61+
describe('PermissionMatrixEditPage — field-level bulk + filter (objectui#2600 B4)', () => {
62+
it('Write all grants read+write to every visible field', async () => {
63+
await renderExpanded();
64+
65+
expect(screen.getByLabelText('a_account.email editable')).not.toBeChecked();
66+
fireEvent.click(screen.getByRole('button', { name: 'Write all' }));
67+
68+
for (const f of FIELDS) {
69+
expect(screen.getByLabelText(`a_account.${f} readable`)).toBeChecked();
70+
expect(screen.getByLabelText(`a_account.${f} editable`)).toBeChecked();
71+
}
72+
});
73+
74+
it('the filter narrows the field set and bulk acts only on what is visible', async () => {
75+
await renderExpanded();
76+
77+
// Grant write to everyone first.
78+
fireEvent.click(screen.getByRole('button', { name: 'Write all' }));
79+
expect(screen.getByLabelText('a_account.email editable')).toBeChecked();
80+
81+
// Filter to just the "re*" fields — region + revenue.
82+
fireEvent.change(screen.getByLabelText('Filter fields…'), { target: { value: 're' } });
83+
expect(screen.getByLabelText('a_account.region readable')).toBeInTheDocument();
84+
expect(screen.getByLabelText('a_account.revenue readable')).toBeInTheDocument();
85+
expect(screen.queryByLabelText('a_account.email readable')).toBeNull();
86+
expect(screen.getByText('2 / 8')).toBeInTheDocument();
87+
88+
// Read-all now only revokes write on the visible (filtered) fields.
89+
fireEvent.click(screen.getByRole('button', { name: 'Read all' }));
90+
expect(screen.getByLabelText('a_account.region editable')).not.toBeChecked();
91+
expect(screen.getByLabelText('a_account.region readable')).toBeChecked();
92+
93+
// Clearing the filter reveals the untouched fields still holding write.
94+
fireEvent.change(screen.getByLabelText('Filter fields…'), { target: { value: '' } });
95+
expect(screen.getByLabelText('a_account.email editable')).toBeChecked();
96+
});
97+
98+
it('Clear drops the overrides so fields fall back to the read-only default', async () => {
99+
await renderExpanded();
100+
101+
fireEvent.click(screen.getByRole('button', { name: 'Write all' }));
102+
expect(screen.getByLabelText('a_account.name editable')).toBeChecked();
103+
104+
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
105+
// Default field posture: readable, not editable.
106+
expect(screen.getByLabelText('a_account.name readable')).toBeChecked();
107+
expect(screen.getByLabelText('a_account.name editable')).not.toBeChecked();
108+
});
109+
});

packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx

Lines changed: 126 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,24 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
513513
});
514514
}
515515

516+
// objectui#2600 B4 — field-level bulk over the fields currently VISIBLE in the
517+
// sub-table (i.e. after the field filter), mirroring the per-object row's
518+
// read/all/none shortcuts. `readable` sets read-only, `writable` grants R+W,
519+
// `clear` drops the explicit overrides so those fields fall back to default.
520+
function bulkSetFields(objectName: string, action: 'readable' | 'writable' | 'clear', fieldNames: string[]) {
521+
if (fieldNames.length === 0) return;
522+
setDraft((prev) => {
523+
const fields = { ...(prev.fields ?? {}) };
524+
for (const fieldName of fieldNames) {
525+
const key = `${objectName}.${fieldName}`;
526+
if (action === 'clear') delete fields[key];
527+
else if (action === 'readable') fields[key] = { readable: true, editable: false };
528+
else fields[key] = { readable: true, editable: true };
529+
}
530+
return { ...prev, fields };
531+
});
532+
}
533+
516534
/**
517535
* Re-narrow a freshly-read full permission set to this package's slice for
518536
* display. No-op at environment scope (no `packageId`).
@@ -815,6 +833,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
815833
onObjectPerm={updateObjectPerm}
816834
onFieldPerm={updateFieldPerm}
817835
onBulkSet={bulkSetObject}
836+
onFieldBulk={bulkSetFields}
818837
onOpenOwd={onOpenOwd}
819838
/>
820839
{/* Advanced facets (ADR-0056 P3) — RLS / tab visibility / delegated
@@ -909,6 +928,8 @@ interface PermissionTableProps {
909928
onObjectPerm: (objectName: string, action: keyof ObjectPerm, value: boolean) => void;
910929
onFieldPerm: (objectName: string, fieldName: string, action: keyof FieldPerm, value: boolean) => void;
911930
onBulkSet: (objectName: string, action: 'all' | 'none' | 'crud' | 'read') => void;
931+
/** objectui#2600 B4 — field-level bulk over the filtered field set. */
932+
onFieldBulk: (objectName: string, action: 'readable' | 'writable' | 'clear', fieldNames: string[]) => void;
912933
/** objectui#2505 — when set, the OWD badge links to the package OWD overview. */
913934
onOpenOwd?: (objectName: string) => void;
914935
}
@@ -925,6 +946,7 @@ function PermissionTable({
925946
onObjectPerm,
926947
onFieldPerm,
927948
onBulkSet,
949+
onFieldBulk,
928950
onOpenOwd,
929951
}: PermissionTableProps) {
930952
return (
@@ -1078,6 +1100,7 @@ function PermissionTable({
10781100
writable={writable}
10791101
t={t}
10801102
onFieldPerm={onFieldPerm}
1103+
onFieldBulk={onFieldBulk}
10811104
/>
10821105
</td>
10831106
</tr>
@@ -1097,14 +1120,19 @@ function FieldsSubTable({
10971120
writable,
10981121
t,
10991122
onFieldPerm,
1123+
onFieldBulk,
11001124
}: {
11011125
objectName: string;
11021126
fields: FieldSummary[] | undefined;
11031127
fieldsState: Record<string, FieldPerm>;
11041128
writable: boolean;
11051129
t: (key: string) => string;
11061130
onFieldPerm: (objectName: string, fieldName: string, action: keyof FieldPerm, value: boolean) => void;
1131+
onFieldBulk: (objectName: string, action: 'readable' | 'writable' | 'clear', fieldNames: string[]) => void;
11071132
}) {
1133+
// objectui#2600 B4 — filter fields and bulk-apply over the visible set, so
1134+
// wide objects (dozens of fields) aren't two-checkboxes-at-a-time tedious.
1135+
const [fieldFilter, setFieldFilter] = React.useState('');
11081136
if (!fields) {
11091137
return (
11101138
<div className="text-xs text-muted-foreground flex items-center gap-2">
@@ -1115,47 +1143,105 @@ function FieldsSubTable({
11151143
if (fields.length === 0) {
11161144
return <div className="text-xs text-muted-foreground">{t('perm.field.empty')}</div>;
11171145
}
1146+
const q = fieldFilter.trim().toLowerCase();
1147+
const visible = q
1148+
? fields.filter((f) => f.name.toLowerCase().includes(q) || (f.label ?? '').toLowerCase().includes(q))
1149+
: fields;
1150+
const visibleNames = visible.map((f) => f.name);
11181151
return (
1119-
<table className="w-full text-xs">
1120-
<thead>
1121-
<tr className="text-muted-foreground">
1122-
<th className="text-left py-1 font-medium">{t('perm.field.col.name')}</th>
1123-
<th className="px-2 py-1 font-medium w-16 text-center" title={t('perm.field.read')}>{t('perm.field.read')}</th>
1124-
<th className="px-2 py-1 font-medium w-16 text-center" title={t('perm.field.edit')}>{t('perm.field.edit')}</th>
1125-
</tr>
1126-
</thead>
1127-
<tbody>
1128-
{fields.map((f) => {
1129-
const key = `${objectName}.${f.name}`;
1130-
const cur = fieldsState[key] ?? { readable: true, editable: false };
1131-
return (
1132-
<tr key={f.name} className="border-t border-muted">
1133-
<td className="py-1">
1134-
{f.label ?? f.name}
1135-
{f.label && (
1136-
<span className="ml-1 text-muted-foreground">({f.name})</span>
1137-
)}
1138-
</td>
1139-
<td className="px-2 py-1 text-center">
1140-
<Checkbox
1141-
checked={!!cur.readable}
1142-
disabled={!writable}
1143-
onCheckedChange={(v) => onFieldPerm(objectName, f.name, 'readable', !!v)}
1144-
aria-label={`${objectName}.${f.name} readable`}
1145-
/>
1146-
</td>
1147-
<td className="px-2 py-1 text-center">
1148-
<Checkbox
1149-
checked={!!cur.editable}
1150-
disabled={!writable}
1151-
onCheckedChange={(v) => onFieldPerm(objectName, f.name, 'editable', !!v)}
1152-
aria-label={`${objectName}.${f.name} editable`}
1153-
/>
1154-
</td>
1152+
<div className="space-y-2">
1153+
{/* Field toolbar (B4): filter + bulk over the visible field set. The
1154+
filter only appears once one-by-one would actually hurt. */}
1155+
<div className="flex flex-wrap items-center gap-2">
1156+
{fields.length > 6 && (
1157+
<Input
1158+
value={fieldFilter}
1159+
onChange={(e) => setFieldFilter(e.target.value)}
1160+
placeholder={t('perm.field.filter')}
1161+
aria-label={t('perm.field.filter')}
1162+
className="h-7 w-44 text-xs"
1163+
/>
1164+
)}
1165+
{writable && (
1166+
<div className="flex items-center gap-1">
1167+
<Button
1168+
variant="ghost"
1169+
size="sm"
1170+
className="h-6 px-1.5 text-xs"
1171+
disabled={visibleNames.length === 0}
1172+
onClick={() => onFieldBulk(objectName, 'readable', visibleNames)}
1173+
>
1174+
{t('perm.field.bulk.readable')}
1175+
</Button>
1176+
<Button
1177+
variant="ghost"
1178+
size="sm"
1179+
className="h-6 px-1.5 text-xs"
1180+
disabled={visibleNames.length === 0}
1181+
onClick={() => onFieldBulk(objectName, 'writable', visibleNames)}
1182+
>
1183+
{t('perm.field.bulk.writable')}
1184+
</Button>
1185+
<Button
1186+
variant="ghost"
1187+
size="sm"
1188+
className="h-6 px-1.5 text-xs"
1189+
disabled={visibleNames.length === 0}
1190+
onClick={() => onFieldBulk(objectName, 'clear', visibleNames)}
1191+
>
1192+
{t('perm.field.bulk.clear')}
1193+
</Button>
1194+
</div>
1195+
)}
1196+
<span className="ml-auto text-[11px] text-muted-foreground">
1197+
{visible.length} / {fields.length}
1198+
</span>
1199+
</div>
1200+
{visible.length === 0 ? (
1201+
<div className="text-xs text-muted-foreground">{t('perm.field.filterEmpty')}</div>
1202+
) : (
1203+
<table className="w-full text-xs">
1204+
<thead>
1205+
<tr className="text-muted-foreground">
1206+
<th className="text-left py-1 font-medium">{t('perm.field.col.name')}</th>
1207+
<th className="px-2 py-1 font-medium w-16 text-center" title={t('perm.field.read')}>{t('perm.field.read')}</th>
1208+
<th className="px-2 py-1 font-medium w-16 text-center" title={t('perm.field.edit')}>{t('perm.field.edit')}</th>
11551209
</tr>
1156-
);
1157-
})}
1158-
</tbody>
1159-
</table>
1210+
</thead>
1211+
<tbody>
1212+
{visible.map((f) => {
1213+
const key = `${objectName}.${f.name}`;
1214+
const cur = fieldsState[key] ?? { readable: true, editable: false };
1215+
return (
1216+
<tr key={f.name} className="border-t border-muted">
1217+
<td className="py-1">
1218+
{f.label ?? f.name}
1219+
{f.label && (
1220+
<span className="ml-1 text-muted-foreground">({f.name})</span>
1221+
)}
1222+
</td>
1223+
<td className="px-2 py-1 text-center">
1224+
<Checkbox
1225+
checked={!!cur.readable}
1226+
disabled={!writable}
1227+
onCheckedChange={(v) => onFieldPerm(objectName, f.name, 'readable', !!v)}
1228+
aria-label={`${objectName}.${f.name} readable`}
1229+
/>
1230+
</td>
1231+
<td className="px-2 py-1 text-center">
1232+
<Checkbox
1233+
checked={!!cur.editable}
1234+
disabled={!writable}
1235+
onCheckedChange={(v) => onFieldPerm(objectName, f.name, 'editable', !!v)}
1236+
aria-label={`${objectName}.${f.name} editable`}
1237+
/>
1238+
</td>
1239+
</tr>
1240+
);
1241+
})}
1242+
</tbody>
1243+
</table>
1244+
)}
1245+
</div>
11601246
);
11611247
}

packages/app-shell/src/views/metadata-admin/i18n.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,11 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
777777
'perm.badge.default': 'Default for new users',
778778
'perm.field.loading': 'Loading fields…',
779779
'perm.field.empty': 'No fields registered for this object.',
780+
'perm.field.filter': 'Filter fields…',
781+
'perm.field.filterEmpty': 'No fields match the filter.',
782+
'perm.field.bulk.readable': 'Read all',
783+
'perm.field.bulk.writable': 'Write all',
784+
'perm.field.bulk.clear': 'Clear',
780785
'perm.field.col.name': 'Field',
781786
'perm.field.read': 'Read',
782787
'perm.field.edit': 'Edit',
@@ -2137,6 +2142,11 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
21372142
'perm.badge.default': '新用户默认',
21382143
'perm.field.loading': '加载字段中…',
21392144
'perm.field.empty': '该对象未注册字段。',
2145+
'perm.field.filter': '筛选字段…',
2146+
'perm.field.filterEmpty': '没有匹配的字段。',
2147+
'perm.field.bulk.readable': '全部可读',
2148+
'perm.field.bulk.writable': '全部可写',
2149+
'perm.field.bulk.clear': '清空',
21402150
'perm.field.col.name': '字段',
21412151
'perm.field.read': '读',
21422152
'perm.field.edit': '改',

0 commit comments

Comments
 (0)