Skip to content

Commit 8d5534f

Browse files
os-zhuangclaude
andauthored
feat(access): Studio permission matrix — collapse identity + zero-grant capabilities so the matrix hits the first screen (#2600 B1) (#2649)
The Access pillar is called "Permission Matrix", but the editor opened with a name/label form plus the capability picker's option chips, pushing the matrix below the fold. Two low-frequency, high-chrome sections now start collapsed: - Identity (name/label) collapses to a one-line summary (label · provenance · default/read-only badges) and expands on click. The api-name already lives in the PageShell breadcrumb, so the summary carries the human label only. - System capabilities: a zero-grant WRITABLE set rendered every option as an outline chip, which read as already-owned. It now shows an explicit "none granted · add" affordance; granted sets and read-only sets keep the inline picker as before. Read-only packages, the dirty/guard contracts (#2588/#2606), the history sheet and breadcrumb (#2599) are all unaffected. Adds PermissionMatrixEditor.basics.test.tsx and updates the settle points of the existing matrix/guard suites (identity is now summary text, not an input). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9d52f94 commit 8d5534f

8 files changed

Lines changed: 235 additions & 79 deletions

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ async function renderWith(extra: Record<string, unknown>) {
4747
<PermissionMatrixEditPage type="permission" name="sales_perms" />
4848
</MemoryRouter>,
4949
);
50-
// Wait for the load to settle (the label input renders after the fetch).
51-
await screen.findByDisplayValue('Sales');
50+
// Wait for the load to settle. The identity strip is collapsed by default
51+
// (objectui#2600 B1), so the label shows as summary text, not an input.
52+
await screen.findByText('Sales');
5253
}
5354

5455
describe('PermissionMatrixEditPage — provenance tri-state badge (A4 framework#2920)', () => {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#2600 B1 — "first screen sees the matrix". The pillar is called the
5+
* Permission Matrix, so the low-frequency identity form (name / label) and the
6+
* capability picker must NOT push the matrix below the fold:
7+
* - the identity strip collapses to a one-line summary and expands on click;
8+
* - a zero-grant writable set shows an explicit "none granted · add"
9+
* affordance instead of the picker's option chips (which read as owned).
10+
*/
11+
12+
import { describe, it, expect, vi, afterEach } from 'vitest';
13+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
14+
import { MemoryRouter } from 'react-router-dom';
15+
16+
function makeClient(set: Record<string, unknown>) {
17+
return {
18+
layered: async () => ({ effective: set, code: null, overlay: null, overlayScope: null }),
19+
getDraft: async () => null,
20+
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
21+
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
22+
save: async (_t: string, _n: string, payload: Record<string, unknown>) => payload,
23+
} as any;
24+
}
25+
26+
let clientImpl: any;
27+
28+
vi.mock('./useMetadata', () => ({
29+
useMetadataClient: () => clientImpl,
30+
useMetadataTypes: () => ({
31+
loading: false,
32+
error: null,
33+
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
34+
}),
35+
}));
36+
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
37+
// Stub the capability picker so the B1 collapse logic is isolated from the
38+
// live sys_capability registry read.
39+
vi.mock('@object-ui/fields', () => ({
40+
CapabilityMultiSelectField: () => <div data-testid="cap-picker" />,
41+
parseCapabilityNames: (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : []),
42+
}));
43+
44+
import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
45+
46+
afterEach(cleanup);
47+
48+
async function renderSet(extra: Record<string, unknown> = {}) {
49+
clientImpl = makeClient({
50+
name: 'sales_perms',
51+
label: 'Sales',
52+
isProfile: false,
53+
objects: {},
54+
fields: {},
55+
...extra,
56+
});
57+
render(
58+
<MemoryRouter>
59+
<PermissionMatrixEditPage type="permission" name="sales_perms" />
60+
</MemoryRouter>,
61+
);
62+
await screen.findByText('Sales');
63+
}
64+
65+
describe('PermissionMatrixEditPage — B1 first-screen collapse (objectui#2600)', () => {
66+
it('collapses the identity form by default and expands on click', async () => {
67+
await renderSet();
68+
69+
// Name/label are summary text, not editable inputs, on first paint.
70+
expect(screen.queryByDisplayValue('sales_perms')).toBeNull();
71+
expect(screen.queryByDisplayValue('Sales')).toBeNull();
72+
expect(screen.getByText('Sales')).toBeInTheDocument();
73+
74+
// Clicking the summary strip (labelled "Sales") reveals the inputs.
75+
fireEvent.click(screen.getByRole('button', { name: /Sales/ }));
76+
expect(screen.getByDisplayValue('sales_perms')).toBeInTheDocument();
77+
expect(screen.getByDisplayValue('Sales')).toBeInTheDocument();
78+
});
79+
80+
it('collapses zero-grant capabilities to a "none · add" affordance', async () => {
81+
await renderSet({ systemPermissions: [] });
82+
83+
// The option-chip picker is hidden; an explicit none-granted line shows.
84+
expect(screen.queryByTestId('cap-picker')).toBeNull();
85+
const add = screen.getByRole('button', { name: /Add capability/i });
86+
expect(add).toBeInTheDocument();
87+
88+
// Opting in reveals the picker.
89+
fireEvent.click(add);
90+
expect(screen.getByTestId('cap-picker')).toBeInTheDocument();
91+
});
92+
93+
it('keeps the picker inline when the set already grants capabilities', async () => {
94+
await renderSet({ systemPermissions: ['studio.access'] });
95+
// Non-empty grants must remain visible without an extra click.
96+
expect(screen.getByTestId('cap-picker')).toBeInTheDocument();
97+
expect(screen.queryByRole('button', { name: /Add capability/i })).toBeNull();
98+
});
99+
});

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ async function renderMatrix(embedded: boolean) {
7171
/>
7272
</MemoryRouter>,
7373
);
74-
await screen.findByDisplayValue('Sales');
74+
// Identity strip is collapsed by default (objectui#2600 B1) — the label is
75+
// summary text, not an input, until the strip is expanded.
76+
await screen.findByText('Sales');
7577
}
7678

7779
describe('PermissionMatrixEditPage — embedded mode (Studio Access pillar)', () => {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@ describe('PermissionMatrixEditPage — read-only package gate (host readOnly)',
114114
expect(within(row).getByRole('button', { name })).toBeDisabled();
115115
}
116116

117-
// Identity inputs are locked too.
117+
// Identity inputs collapse by default (objectui#2600 B1) — expand the
118+
// strip (summary shows the label "Sales"), then confirm they are locked
119+
// too in a read-only package.
120+
fireEvent.click(screen.getByRole('button', { name: /Sales/ }));
118121
expect(screen.getByDisplayValue('sales_perms')).toBeDisabled();
119122
expect(screen.getByDisplayValue('Sales')).toBeDisabled();
120123

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

Lines changed: 101 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
248248
>(null);
249249
const [filter, setFilter] = React.useState('');
250250
const [showOnlyEnabled, setShowOnlyEnabled] = React.useState(false);
251+
// objectui#2600 B1 — the pillar is called "Permission Matrix", so the matrix
252+
// must reach the first screen. Name/label change rarely and the capability
253+
// picker floods the top with option chips even at zero grants, so both start
254+
// collapsed to a one-line summary and the user opts in to edit them.
255+
const [basicsOpen, setBasicsOpen] = React.useState(false);
256+
const [capsOpen, setCapsOpen] = React.useState(false);
251257
// Embedded-mode History sheet (see `embedded` prop doc).
252258
const [historyOpen, setHistoryOpen] = React.useState(false);
253259
// All permission-set api-names — the admin-scope editor's assignable
@@ -648,58 +654,76 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
648654
</div>
649655
)}
650656

651-
{/* Header strip — name / label / provenance + default badges */}
652-
<div className="px-6 py-3 border-b bg-muted/30 flex flex-wrap items-end gap-4">
653-
<div className="space-y-1">
654-
<Label htmlFor="perm-name" className="text-xs">{t('perm.field.name')}</Label>
655-
<Input
656-
id="perm-name"
657-
value={draft.name}
658-
disabled={!writable}
659-
onChange={(e) => setDraft((p) => ({ ...p, name: e.target.value }))}
660-
className="h-8 w-56"
661-
/>
662-
</div>
663-
<div className="space-y-1">
664-
<Label htmlFor="perm-label" className="text-xs">{t('perm.field.label')}</Label>
665-
<Input
666-
id="perm-label"
667-
value={draft.label ?? ''}
668-
disabled={!writable}
669-
onChange={(e) => setDraft((p) => ({ ...p, label: e.target.value }))}
670-
className="h-8 w-72"
671-
/>
672-
</div>
673-
{/* ADR-0090 D2: the profile toggle is gone. What matters instead is
674-
WHO OWNS the set (provenance, ADR-0086 D3) and whether it is the
675-
package's suggested default (ADR-0090 D5). */}
676-
<div className="flex items-center gap-1.5 pb-1">
657+
{/* Identity summary — collapsible (objectui#2600 B1). Name/label change
658+
rarely, so the strip collapses to a one-line summary and the matrix
659+
reaches the first screen; the row toggles the editable inputs open. */}
660+
<div className="border-b bg-muted/30">
661+
<button
662+
type="button"
663+
onClick={() => setBasicsOpen((o) => !o)}
664+
aria-expanded={basicsOpen}
665+
className="w-full px-6 py-2.5 flex items-center gap-2 text-left hover:bg-muted/50 transition-colors"
666+
>
667+
{basicsOpen ? (
668+
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
669+
) : (
670+
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
671+
)}
672+
{/* The api-name already sits in the PageShell breadcrumb (font-mono),
673+
so the summary carries the human label — no need to repeat it. */}
674+
<span className="font-medium truncate">{draft.label || draft.name}</span>
677675
{/* [A4 framework#2920] Provenance tri-state — platform / package /
678-
admin(custom) — mirrors the unified sys_* `managed_by` vocab so
679-
the Studio matrix and the Setup record page read the same. */}
680-
<Badge variant="outline" className="text-[10px]">
676+
admin(custom) — mirrors the unified sys_* `managed_by` vocab. */}
677+
<Badge variant="outline" className="text-[10px] shrink-0">
681678
{draft.managedBy === 'platform'
682679
? t('perm.badge.platform')
683680
: draft.managedBy === 'package' || packageId
684681
? t('perm.badge.package')
685682
: t('perm.badge.custom')}
686683
</Badge>
687684
{!!draft.isDefault && (
688-
<Badge variant="secondary" className="text-[10px]">{t('perm.badge.default')}</Badge>
685+
<Badge variant="secondary" className="text-[10px] shrink-0">{t('perm.badge.default')}</Badge>
689686
)}
690-
</div>
691-
{!writable && (
692-
// Same badge slot, two distinct reasons: a read-only PACKAGE
693-
// (host gate — mirror the top-bar wording so the screen is not
694-
// self-contradictory) vs. metadata writes disabled environment-
695-
// wide (type gate).
696-
<Badge
697-
variant="secondary"
698-
className="ml-auto"
699-
title={readOnly ? t('engine.studio.pkg.readonlyHint') : undefined}
700-
>
701-
{readOnly ? t('engine.studio.pkg.readonly') : t('perm.readOnly')}
702-
</Badge>
687+
{writable && (
688+
<span className="text-xs text-muted-foreground shrink-0">{t('perm.basics.editHint')}</span>
689+
)}
690+
{!writable && (
691+
// Same badge slot, two distinct reasons: a read-only PACKAGE
692+
// (host gate — mirror the top-bar wording so the screen is not
693+
// self-contradictory) vs. metadata writes disabled environment-
694+
// wide (type gate).
695+
<Badge
696+
variant="secondary"
697+
className="ml-auto shrink-0"
698+
title={readOnly ? t('engine.studio.pkg.readonlyHint') : undefined}
699+
>
700+
{readOnly ? t('engine.studio.pkg.readonly') : t('perm.readOnly')}
701+
</Badge>
702+
)}
703+
</button>
704+
{basicsOpen && (
705+
<div className="px-6 pb-3 flex flex-wrap items-end gap-4">
706+
<div className="space-y-1">
707+
<Label htmlFor="perm-name" className="text-xs">{t('perm.field.name')}</Label>
708+
<Input
709+
id="perm-name"
710+
value={draft.name}
711+
disabled={!writable}
712+
onChange={(e) => setDraft((p) => ({ ...p, name: e.target.value }))}
713+
className="h-8 w-56"
714+
/>
715+
</div>
716+
<div className="space-y-1">
717+
<Label htmlFor="perm-label" className="text-xs">{t('perm.field.label')}</Label>
718+
<Input
719+
id="perm-label"
720+
value={draft.label ?? ''}
721+
disabled={!writable}
722+
onChange={(e) => setDraft((p) => ({ ...p, label: e.target.value }))}
723+
className="h-8 w-72"
724+
/>
725+
</div>
726+
</div>
703727
)}
704728
</div>
705729

@@ -709,21 +733,40 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
709733
as PermissionSetDraft.systemPermissions (string[]); the picker
710734
round-trips via a JSON string, so parse back into the array the
711735
draft model uses. Persisted by the whole-record Save at env scope. */}
712-
<div className="px-6 py-3 border-b">
713-
<Label className="text-xs">{t('perm.field.systemCapabilities')}</Label>
714-
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
715-
{t('perm.field.systemCapabilitiesHelp')}
716-
</p>
717-
<CapabilityMultiSelectField
718-
value={JSON.stringify(draft.systemPermissions ?? [])}
719-
onChange={(v: unknown) =>
720-
setDraft((p) => ({ ...p, systemPermissions: parseCapabilityNames(v) }))
721-
}
722-
field={{ name: 'system_permissions' } as any}
723-
dataSource={adapter as any}
724-
readonly={!writable}
725-
/>
726-
</div>
736+
{writable && (draft.systemPermissions ?? []).length === 0 && !capsOpen ? (
737+
// objectui#2600 B1 — zero-grant writable sets used to render the full
738+
// picker's option chips, which read as already-owned capabilities.
739+
// Collapse to an explicit "none granted · add" affordance instead.
740+
<div className="px-6 py-2.5 border-b flex items-center gap-2 text-xs">
741+
<Label className="text-xs text-muted-foreground">{t('perm.field.systemCapabilities')}</Label>
742+
<span className="text-muted-foreground">·</span>
743+
<span className="text-muted-foreground">{t('perm.cap.none')}</span>
744+
<Button
745+
variant="ghost"
746+
size="sm"
747+
className="h-6 px-2 text-xs"
748+
onClick={() => setCapsOpen(true)}
749+
>
750+
+ {t('perm.cap.add')}
751+
</Button>
752+
</div>
753+
) : (
754+
<div className="px-6 py-3 border-b">
755+
<Label className="text-xs">{t('perm.field.systemCapabilities')}</Label>
756+
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
757+
{t('perm.field.systemCapabilitiesHelp')}
758+
</p>
759+
<CapabilityMultiSelectField
760+
value={JSON.stringify(draft.systemPermissions ?? [])}
761+
onChange={(v: unknown) =>
762+
setDraft((p) => ({ ...p, systemPermissions: parseCapabilityNames(v) }))
763+
}
764+
field={{ name: 'system_permissions' } as any}
765+
dataSource={adapter as any}
766+
readonly={!writable}
767+
/>
768+
</div>
769+
)}
727770

728771
{/* Filter bar */}
729772
<div className="px-6 py-3 border-b flex items-center gap-3">

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,9 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
768768
'perm.filter.empty': 'No objects match the filter.',
769769
'perm.field.name': 'Name',
770770
'perm.field.label': 'Label',
771+
'perm.basics.editHint': 'Edit name / label',
772+
'perm.cap.none': 'No system capabilities',
773+
'perm.cap.add': 'Add capability',
771774
'perm.badge.platform': 'Platform',
772775
'perm.badge.package': 'Package',
773776
'perm.badge.custom': 'Custom',
@@ -2124,6 +2127,9 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
21242127
'perm.filter.empty': '没有匹配的对象。',
21252128
'perm.field.name': '名称',
21262129
'perm.field.label': '标签',
2130+
'perm.basics.editHint': '编辑名称 / 标签',
2131+
'perm.cap.none': '无附加系统能力',
2132+
'perm.cap.add': '添加能力',
21272133
'perm.badge.platform': '平台内置',
21282134
'perm.badge.package': '包内置',
21292135
'perm.badge.custom': '本环境自定义',

0 commit comments

Comments
 (0)