Skip to content

Commit a6c4f4d

Browse files
os-zhuangclaude
andauthored
feat(access): Studio Explain panel — package-scoped object dropdown instead of free-text api-name (#2600 B2) (#2650)
The "why can this user access?" panel asked for the object as free text, with a placeholder (`crm_lead`) that isn't even in the package — so it invited typos and named a non-existent object. The Access pillar already knows its package, which has only ~20 objects. - AccessExplainPanel takes an optional `packageId`; when set it loads that package's objects (same `list('object', {packageId})` the matrix uses) and renders the object field as a `<select>` of `label (api-name)`. - Free-text input stays as a graceful fallback when unscoped or the list fails. - Placeholder no longer names a non-existent object. - StudioDesignSurface passes `packageId` to the panel. The Explain button was already correctly disabled (opacity-50) until an object is chosen; the dropdown makes choosing one a clear affordance. Adds dropdown + fallback coverage to AccessExplainPanel.test.tsx. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8d5534f commit a6c4f4d

4 files changed

Lines changed: 110 additions & 12 deletions

File tree

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,22 @@
1212

1313
import '@testing-library/jest-dom/vitest';
1414
import { afterEach, describe, expect, it, vi } from 'vitest';
15-
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
15+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
1616

1717
const fetchSpy = vi.fn();
18+
// objectui#2600 B2 — package object list backing the object dropdown; a
19+
// `mock`-prefixed name so vitest allows the hoisted factory to close over it.
20+
let mockObjectList: Array<Record<string, unknown>> = [];
1821

1922
vi.mock('@object-ui/auth', () => ({
2023
createAuthenticatedFetch: () => fetchSpy,
2124
}));
2225
vi.mock('@object-ui/react', () => ({
2326
useAdapter: () => ({ find: vi.fn(async () => []) }),
2427
}));
28+
vi.mock('./useMetadata', () => ({
29+
useMetadataClient: () => ({ list: async () => mockObjectList }),
30+
}));
2531
// The user picker has its own coverage; a stub keeps this suite focused on
2632
// the explain round-trip.
2733
vi.mock('@object-ui/fields', () => ({
@@ -33,6 +39,7 @@ import { AccessExplainPanel, type ExplainDecision } from './AccessExplainPanel';
3339
afterEach(() => {
3440
cleanup();
3541
fetchSpy.mockReset();
42+
mockObjectList = [];
3643
});
3744

3845
const DECISION: ExplainDecision = {
@@ -181,4 +188,36 @@ describe('AccessExplainPanel (ADR-0090 D6)', () => {
181188
expect(screen.getByRole('alert').textContent).toMatch(/manage_users|delegated admin scope/i);
182189
expect(screen.queryByTestId('explain-verdict')).not.toBeInTheDocument();
183190
});
191+
192+
// objectui#2600 B2 — with a package scope, the object field becomes a
193+
// dropdown of the package's objects instead of a free-text api-name input.
194+
it('renders a package-scoped object dropdown when packageId is set', async () => {
195+
mockObjectList = [
196+
{ item: { name: 'showcase_account', label: 'Account' } },
197+
{ item: { name: 'showcase_task' } },
198+
];
199+
render(<AccessExplainPanel open onOpenChange={() => {}} packageId="com.example.showcase" />);
200+
201+
// The object field swaps from a free-text input to a <select> once loaded.
202+
await waitFor(() => expect(screen.getByLabelText('Object').tagName).toBe('SELECT'));
203+
const objectField = screen.getByLabelText('Object');
204+
expect(within(objectField).getByRole('option', { name: 'Account (showcase_account)' })).toBeInTheDocument();
205+
expect(within(objectField).getByRole('option', { name: 'showcase_task' })).toBeInTheDocument();
206+
// The misleading free-text placeholder example is gone.
207+
expect(screen.queryByPlaceholderText(/crm_lead/)).toBeNull();
208+
209+
// Selecting an object and explaining posts that object.
210+
fetchSpy.mockResolvedValue(jsonResponse(200, DECISION));
211+
fireEvent.change(objectField, { target: { value: 'showcase_account' } });
212+
fireEvent.click(screen.getByRole('button', { name: /explain$/i }));
213+
await waitFor(() => expect(fetchSpy).toHaveBeenCalled());
214+
const body = JSON.parse(String((fetchSpy.mock.calls[0]?.[1] as any)?.body ?? '{}'));
215+
expect(body.object).toBe('showcase_account');
216+
});
217+
218+
// Unscoped (no packageId) keeps the free-text input as a fallback.
219+
it('falls back to a free-text object input without a package scope', async () => {
220+
renderPanel();
221+
expect(screen.getByLabelText('Object').tagName).toBe('INPUT');
222+
});
184223
});

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

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
XCircle,
5252
} from 'lucide-react';
5353
import { t, tFormat, useMetadataLocale } from './i18n';
54+
import { useMetadataClient } from './useMetadata';
5455

5556
/** Mirrors `ExplainOperationSchema` in `@objectstack/spec/security`. */
5657
const OPERATIONS = ['read', 'create', 'update', 'delete', 'transfer', 'restore', 'purge'] as const;
@@ -159,13 +160,50 @@ export interface AccessExplainPanelProps {
159160
onOpenChange: (open: boolean) => void;
160161
/** Optional prefill for the object input (e.g. from the matrix). */
161162
defaultObject?: string;
163+
/**
164+
* objectui#2600 B2 — the package this panel is scoped to. When set, the
165+
* object field becomes a dropdown of the package's objects (only ~20 exist)
166+
* instead of a free-text api-name input whose example never resolves.
167+
*/
168+
packageId?: string;
162169
}
163170

164-
export function AccessExplainPanel({ open, onOpenChange, defaultObject }: AccessExplainPanelProps): React.ReactElement {
171+
export function AccessExplainPanel({ open, onOpenChange, defaultObject, packageId }: AccessExplainPanelProps): React.ReactElement {
165172
const locale = useMetadataLocale();
166173
const adapter = useAdapter() as any;
174+
const client = useMetadataClient();
167175
const authFetch = React.useMemo(() => createAuthenticatedFetch(), []);
168176

177+
// objectui#2600 B2 — package object list backing the object dropdown. `null`
178+
// until loaded (or when unscoped / load fails), which keeps the free-text
179+
// input as a graceful fallback.
180+
const [objectOptions, setObjectOptions] = React.useState<Array<{ name: string; label?: string }> | null>(null);
181+
React.useEffect(() => {
182+
if (!open || !packageId) {
183+
setObjectOptions(null);
184+
return;
185+
}
186+
let cancelled = false;
187+
void (async () => {
188+
try {
189+
const rows = (await client.list<any>('object', { packageId })) as any[];
190+
const opts = (rows ?? [])
191+
.map((row) => {
192+
const item = row?.item ?? row;
193+
return { name: String(item?.name ?? ''), label: item?.label as string | undefined };
194+
})
195+
.filter((o) => !!o.name)
196+
.sort((a, b) => a.name.localeCompare(b.name));
197+
if (!cancelled) setObjectOptions(opts);
198+
} catch {
199+
if (!cancelled) setObjectOptions(null);
200+
}
201+
})();
202+
return () => {
203+
cancelled = true;
204+
};
205+
}, [open, packageId, client]);
206+
169207
const [objectName, setObjectName] = React.useState(defaultObject ?? '');
170208
const [operation, setOperation] = React.useState<ExplainOperation>('read');
171209
const [user, setUser] = React.useState<{ id: string; label: string } | null>(null);
@@ -281,13 +319,32 @@ export function AccessExplainPanel({ open, onOpenChange, defaultObject }: Access
281319
<label htmlFor="explain-object" className="text-xs font-medium text-muted-foreground">
282320
{t('engine.studio.access.explain.object', locale)}
283321
</label>
284-
<Input
285-
id="explain-object"
286-
value={objectName}
287-
onChange={(e) => setObjectName(e.target.value)}
288-
placeholder={t('engine.studio.access.explain.objectPlaceholder', locale)}
289-
className="h-8 text-xs"
290-
/>
322+
{objectOptions && objectOptions.length > 0 ? (
323+
// objectui#2600 B2 — package-scoped dropdown: the object must be
324+
// one of this package's ~20 objects, so free-text (with an
325+
// example that isn't even in the package) only invites typos.
326+
<select
327+
id="explain-object"
328+
value={objectName}
329+
onChange={(e) => setObjectName(e.target.value)}
330+
className="h-8 w-full rounded-md border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-primary"
331+
>
332+
<option value="">{t('engine.studio.access.explain.objectSelect', locale)}</option>
333+
{objectOptions.map((o) => (
334+
<option key={o.name} value={o.name}>
335+
{o.label && o.label !== o.name ? `${o.label} (${o.name})` : o.name}
336+
</option>
337+
))}
338+
</select>
339+
) : (
340+
<Input
341+
id="explain-object"
342+
value={objectName}
343+
onChange={(e) => setObjectName(e.target.value)}
344+
placeholder={t('engine.studio.access.explain.objectPlaceholder', locale)}
345+
className="h-8 text-xs"
346+
/>
347+
)}
291348
</div>
292349
<div className="w-32 shrink-0 space-y-1">
293350
<label htmlFor="explain-operation" className="text-xs font-medium text-muted-foreground">

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,7 +1423,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
14231423
'engine.studio.access.explain.clearUser': 'Reset to me',
14241424
'engine.studio.access.explain.pickUserTitle': 'Select the user to explain',
14251425
'engine.studio.access.explain.object': 'Object',
1426-
'engine.studio.access.explain.objectPlaceholder': 'Object name (e.g. crm_lead)',
1426+
'engine.studio.access.explain.objectPlaceholder': 'Object api-name',
1427+
'engine.studio.access.explain.objectSelect': 'Select an object…',
14271428
'engine.studio.access.explain.operation': 'Operation',
14281429
'engine.studio.access.explain.run': 'Explain',
14291430
'engine.studio.access.explain.running': 'Explaining…',
@@ -2777,7 +2778,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
27772778
'engine.studio.access.explain.clearUser': '重置为我',
27782779
'engine.studio.access.explain.pickUserTitle': '选择要解释的用户',
27792780
'engine.studio.access.explain.object': '对象',
2780-
'engine.studio.access.explain.objectPlaceholder': '对象名(如:crm_lead)',
2781+
'engine.studio.access.explain.objectPlaceholder': '对象 API 名称',
2782+
'engine.studio.access.explain.objectSelect': '选择对象…',
27812783
'engine.studio.access.explain.operation': '操作',
27822784
'engine.studio.access.explain.run': '解释',
27832785
'engine.studio.access.explain.running': '解释中…',

packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3583,7 +3583,7 @@ export function AccessPillar({
35833583
</main>
35843584
</div>
35853585

3586-
<AccessExplainPanel open={explainOpen} onOpenChange={setExplainOpen} />
3586+
<AccessExplainPanel open={explainOpen} onOpenChange={setExplainOpen} packageId={packageId} />
35873587

35883588
<CreateItemDialog
35893589
open={creating}

0 commit comments

Comments
 (0)