Skip to content

Commit fcbf1b2

Browse files
feat(app-shell): confirm a permission set or position deactivation, naming the holder count (objectui#13338) (#13359)
Closes #13338 Clause-②: no - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) ## What changed Setup's Permission Sets and Positions lists (the metadata list in its environment scope, `DefaultMetadataList` in `ResourceListPage.tsx`, the row's `CatalogActiveCell` switch): - **Switching OFF asks first.** A permission set's or a position's switch opens an in-app `AlertDialog` (the `@object-ui/components` one, as `PackagesPage` uses; never `window.confirm`) titled with the item's label, naming how many users lose the access. Nothing is written until **Deactivate** is pressed; **Cancel** leaves the item on and writes nothing. - **The count is the server's, read when the confirm opens** (new `catalog-holders.ts`, through the console's data source `useAdapter().find`, the same reads the item's own holders sections make): - position: its `sys_user_position` rows, by the position's name; - permission set: its `sys_user_permission_set` rows by the set's name, plus the `sys_user_position` holders of every position whose registry definition lists the set (`positionsDistributing`, reused from `AssignedUsersSection`), one person counted once; - a set bound to the `everyone` anchor is said to reach every signed-in member (not counted); - an answer the server reports as longer than one read is said as "At least N users"; - a refused read is said as such with the server's sentence, never as "0 users", and Deactivate stays available (the confirm informs; it is not a gate). While the count is being read the dialog says so and Deactivate waits. - **Switching ON asks nothing** and writes at once. - **A toast confirms the change either way** (`sonner`, as the rest of metadata-admin uses): "NAME activated." / "NAME deactivated." - Strings live in the engine table `metadata-admin/i18n.ts` (en + zh), where every other `engine.catalog.*` string of this page lives; no locale pack under `packages/i18n` is touched. - Out of scope by the ruling and not done: any refusal of a deactivation. The declared sharing rules list shares the switch and is unchanged (no confirm, no toast). ## Tests - New `ResourceListPage.deactivateConfirm-13338.test.tsx`: on both lists (a `describe.each` over Permission Sets and Positions) the confirm names the count and writes nothing until confirmed, then writes off and toasts; Cancel writes nothing and leaves it on; a refused count is said and not read as zero. Switching on writes at once with no confirm and no holder read, and toasts, on both lists. Singular, everyone-anchor and the exact reads (grant by set name, position holders by position name) are pinned. - New `catalog-holders.test.ts`: one person once, a `hasMore` answer as a lower bound (with control), rejection on no data source and on a refused read. - `ResourceListPage.environmentScope-7611.test.tsx` and `ResourceListPage.positionGrants-25333.test.tsx`: the two existing tests that switch an item off now go through the confirm. Run from the worktree root: - `pnpm exec vitest run` over `packages/app-shell/src/views/metadata-admin/ResourceListPage` (all seven list-page files), `catalog-holders.test.ts`, `walledCatalogReadOnly-12183.test.tsx`, `metadata-form-i18n.test.ts`, `zh-base-term.i18n-13131.test.ts`, `studio-locale.i18n.test.tsx`, `AssignedUsersSection.test.tsx` and `packages/app-shell/src/__tests__/product-neutral-renderer.ratchet.test.ts`: 13 files, 147 tests passed. - `NODE_OPTIONS=--max-old-space-size=8192 pnpm exec turbo run type-check --filter=@object-ui/app-shell`: 30/30 tasks successful. - `pnpm exec eslint` on the changed files: 0 errors; `ResourceListPage.tsx` warning count equal to its base. - `node scripts/check-changeset-presence.mjs`, `node scripts/check-changeset-no-major.mjs`, `pnpm check:new-line-citations`: pass. Session: `https://claude.ai/code/session_01WJozybDmRH8z2c5SD7vJkj` 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WJozybDmRH8z2c5SD7vJkj Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2c4e5f5 commit fcbf1b2

8 files changed

Lines changed: 645 additions & 4 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Setup's Permission Sets and Positions lists ask before the Active switch turns an item off, and a toast confirms the change either way (objectui#13338).
6+
7+
Switching a permission set or a position off opens a confirm that names how many users lose the access. The count is read from the server when the confirm opens: for a position, its `sys_user_position` rows; for a permission set, its `sys_user_permission_set` rows and the holders of every position whose definition lists the set, one person counted once, as the set's Assigned Users section counts them. A set bound to the `everyone` position is said to reach every signed-in member. A count that cannot be read is said as such, never as "0 users", and the admin may still go ahead. Cancel leaves the item on. Switching an item on asks nothing.
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#13338 — the Setup catalog's Active switch on the Permission Sets
5+
* and Positions lists.
6+
*
7+
* What is pinned, on both lists:
8+
*
9+
* - switching OFF asks first and sends nothing until the admin confirms; the
10+
* confirm names how many users lose the access, counted from the server's
11+
* assignment rows when it opens (`sys_user_permission_set` and the holders
12+
* of every position that distributes the set; `sys_user_position` for a
13+
* position), one person once;
14+
* - Cancel leaves the item on and writes nothing;
15+
* - switching ON asks nothing and writes at once;
16+
* - a toast confirms the change either way;
17+
* - a refused count read is said as such, never as "0 users", and the admin
18+
* may still go ahead (the confirm informs, it does not gate).
19+
*/
20+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
21+
import { render, cleanup, screen, waitFor, fireEvent } from '@testing-library/react';
22+
import '@testing-library/jest-dom';
23+
import * as React from 'react';
24+
import { MemoryRouter } from 'react-router-dom';
25+
// The switch column waits on `audienceAnchorsOf`'s dynamic import of this exact
26+
// specifier; loaded here so its cost lands in the import phase.
27+
import '@objectstack/spec/identity';
28+
29+
const PROJECT_PKG = 'com.example.showcase';
30+
31+
const PERMISSION_SETS: Record<string, unknown>[] = [
32+
{ name: 'sales_rep', label: 'Sales Rep', _packageId: PROJECT_PKG, _provenance: 'package' },
33+
{ name: 'qa_fls_rep', label: 'QA FLS Rep', _packageId: PROJECT_PKG, _provenance: 'package' },
34+
{ name: 'member_default', label: 'Member Default', _packageId: PROJECT_PKG, _provenance: 'package' },
35+
];
36+
37+
/** The registry's position definitions: the binding to a set is part of the definition. */
38+
const POSITIONS: Record<string, unknown>[] = [
39+
{ name: 'everyone', label: 'Everyone', permissionSets: ['member_default'], _packageId: PROJECT_PKG, _provenance: 'package' },
40+
{ name: 'sales_manager', label: 'Sales Manager', permissionSets: ['sales_rep'], _packageId: PROJECT_PKG, _provenance: 'package' },
41+
{ name: 'auditor', label: 'Auditor', permissionSets: [], _packageId: PROJECT_PKG, _provenance: 'package' },
42+
];
43+
44+
/** Stable singleton — the page's load effect keys on the client identity. */
45+
const CLIENT = {
46+
list: async (type: string) =>
47+
type === 'package'
48+
? [{ manifest: { id: PROJECT_PKG, scope: 'project', name: 'Showcase' } }]
49+
: type === 'permission'
50+
? PERMISSION_SETS
51+
: type === 'position'
52+
? POSITIONS
53+
: [],
54+
};
55+
56+
const ENTRIES = [
57+
{ type: 'permission', label: 'Permission Set', allowEnvironmentOverlay: false, allowRuntimeCreate: true },
58+
{ type: 'position', label: 'Position', allowEnvironmentOverlay: false, allowRuntimeCreate: true },
59+
];
60+
61+
vi.mock('./useMetadata', async (importOriginal) => ({
62+
...(await importOriginal<Record<string, unknown>>()),
63+
useMetadataClient: () => CLIENT,
64+
useMetadataTypes: () => ({ loading: false, error: null, entries: ENTRIES }),
65+
}));
66+
67+
vi.mock('./i18n', async (importOriginal) => ({
68+
...(await importOriginal<Record<string, unknown>>()),
69+
useMetadataLocale: () => 'en-US',
70+
}));
71+
72+
vi.mock('../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => true }));
73+
vi.mock('../../hooks/useTenancyPosture', async (importOriginal) => ({
74+
...(await importOriginal<Record<string, unknown>>()),
75+
useTenancyPosture: () => 'single',
76+
}));
77+
78+
const toasts = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
79+
vi.mock('sonner', () => ({ toast: toasts }));
80+
81+
/** The assignment rows the server holds, by object. */
82+
let data: Record<string, Record<string, unknown>[]> = {};
83+
/** When set, every holder read is refused with this sentence. */
84+
let holderRefusal: string | null = null;
85+
const adapter = {
86+
find: vi.fn(async (object: string, query: { $filter?: Record<string, unknown> } = {}) => {
87+
if (holderRefusal) throw new Error(holderRefusal);
88+
const filter = query.$filter ?? {};
89+
return {
90+
data: (data[object] ?? []).filter((r) =>
91+
Object.entries(filter).every(([k, v]) =>
92+
v && typeof v === 'object' && Array.isArray((v as { $in?: unknown[] }).$in)
93+
? (v as { $in: unknown[] }).$in.includes(r[k])
94+
: r[k] === v,
95+
),
96+
),
97+
};
98+
}),
99+
};
100+
const metadataStore = { ensureType: vi.fn(async (type: string) => (type === 'position' ? POSITIONS : [])) };
101+
vi.mock('@object-ui/react', async (importOriginal) => ({
102+
...(await importOriginal<Record<string, unknown>>()),
103+
useAdapter: () => adapter,
104+
useMetadata: () => metadataStore,
105+
}));
106+
107+
let ledgerRows: Record<string, unknown>[] = [];
108+
const posts: Array<{ url: string; body?: string }> = [];
109+
110+
// Imported AFTER the mocks so the page picks them up.
111+
import { MetadataResourceListPage } from './ResourceListPage';
112+
import { t, tFormat } from './i18n';
113+
114+
beforeEach(() => {
115+
holderRefusal = null;
116+
data = {
117+
sys_user_permission_set: [
118+
{ id: 'g1', permission_set: 'sales_rep', user_id: 'u1' },
119+
{ id: 'g2', permission_set: 'sales_rep', user_id: 'u2' },
120+
{ id: 'g3', permission_set: 'qa_fls_rep', user_id: 'u9' },
121+
],
122+
sys_user_position: [
123+
// u2 also holds the set directly: counted once.
124+
{ id: 'p1', position: 'sales_manager', user_id: 'u2' },
125+
{ id: 'p2', position: 'sales_manager', user_id: 'u3' },
126+
{ id: 'p3', position: 'auditor', user_id: 'u7' },
127+
],
128+
};
129+
ledgerRows = [{ metadata_type: 'position', name: 'auditor', active: false }];
130+
posts.length = 0;
131+
adapter.find.mockClear();
132+
Object.values(toasts).forEach((f) => f.mockClear());
133+
vi.stubGlobal(
134+
'fetch',
135+
vi.fn(async (url: string, init?: RequestInit) => {
136+
const method = init?.method ?? 'GET';
137+
const json = (status: number, body: unknown) =>
138+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
139+
if (method === 'GET' && String(url).startsWith('/api/v1/data/sys_metadata_activation')) {
140+
return json(200, { object: 'sys_metadata_activation', records: ledgerRows, hasMore: false });
141+
}
142+
if (method === 'POST' && String(url).startsWith('/api/v1/security/_activation/')) {
143+
posts.push({ url: String(url), body: init?.body as string | undefined });
144+
const enabled = JSON.parse(String(init?.body ?? '{}')).enabled;
145+
return json(200, { success: true, data: { enabled } });
146+
}
147+
return json(404, { success: false, error: { message: `unexpected ${method} ${url}` } });
148+
}),
149+
);
150+
});
151+
afterEach(() => {
152+
cleanup();
153+
vi.unstubAllGlobals();
154+
});
155+
156+
function mount(type: 'permission' | 'position') {
157+
return render(
158+
<MemoryRouter initialEntries={[`/apps/setup/metadata/${type}?scope=environment`]}>
159+
<MetadataResourceListPage type={type} />
160+
</MemoryRouter>,
161+
);
162+
}
163+
164+
/** Click a switch that is on, and wait for the confirm's count to arrive. */
165+
async function askToSwitchOff(name: string) {
166+
const sw = await screen.findByTestId(`catalog-active-${name}`);
167+
await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'true'));
168+
fireEvent.click(sw);
169+
const confirm = await screen.findByTestId('catalog-deactivate-confirm');
170+
await waitFor(() => expect(confirm).not.toBeDisabled());
171+
return { sw, confirm };
172+
}
173+
174+
describe.each([
175+
{
176+
list: 'Permission Sets',
177+
type: 'permission' as const,
178+
name: 'sales_rep',
179+
label: 'Sales Rep',
180+
// u1, u2 directly; u2, u3 through sales_manager — three people.
181+
holders: 3,
182+
},
183+
{
184+
list: 'Positions',
185+
type: 'position' as const,
186+
name: 'sales_manager',
187+
label: 'Sales Manager',
188+
holders: 2,
189+
},
190+
])('the $list list: switching off asks first (objectui#13338)', ({ type, name, label, holders }) => {
191+
it('names how many users lose the access, and writes nothing until confirmed', async () => {
192+
mount(type);
193+
await askToSwitchOff(name);
194+
expect(screen.getByTestId('catalog-deactivate-dialog')).toHaveTextContent(
195+
tFormat('engine.catalog.deactivate.title', 'en-US', { name: label }),
196+
);
197+
expect(screen.getByTestId('catalog-deactivate-impact')).toHaveTextContent(
198+
tFormat('engine.catalog.deactivate.holders', 'en-US', { count: holders }),
199+
);
200+
expect(posts).toEqual([]);
201+
202+
fireEvent.click(screen.getByTestId('catalog-deactivate-confirm'));
203+
await waitFor(() =>
204+
expect(screen.getByTestId(`catalog-active-${name}`)).toHaveAttribute('aria-checked', 'false'),
205+
);
206+
expect(posts).toEqual([{ url: `/api/v1/security/_activation/${type}/${name}`, body: '{"enabled":false}' }]);
207+
expect(toasts.success).toHaveBeenCalledWith(
208+
tFormat('engine.catalog.active.switchedOff', 'en-US', { name: label }),
209+
);
210+
});
211+
212+
it('Cancel leaves it on and writes nothing', async () => {
213+
mount(type);
214+
const { sw } = await askToSwitchOff(name);
215+
fireEvent.click(screen.getByTestId('catalog-deactivate-cancel'));
216+
await waitFor(() => expect(screen.queryByTestId('catalog-deactivate-dialog')).toBeNull());
217+
expect(sw).toHaveAttribute('aria-checked', 'true');
218+
expect(posts).toEqual([]);
219+
expect(toasts.success).not.toHaveBeenCalled();
220+
});
221+
222+
it('a refused count is said as such, never as "0 users", and the admin may still go ahead', async () => {
223+
holderRefusal = 'Reading the assignments was refused.';
224+
mount(type);
225+
const { confirm } = await askToSwitchOff(name);
226+
const impact = screen.getByTestId('catalog-deactivate-impact');
227+
expect(impact).toHaveTextContent(
228+
tFormat('engine.catalog.deactivate.countFailed', 'en-US', { message: holderRefusal }),
229+
);
230+
expect(impact).not.toHaveTextContent(tFormat('engine.catalog.deactivate.holders', 'en-US', { count: 0 }));
231+
fireEvent.click(confirm);
232+
await waitFor(() => expect(posts).toHaveLength(1));
233+
});
234+
});
235+
236+
describe('switching on asks nothing (objectui#13338)', () => {
237+
it('on the Positions list, writes at once and toasts; CONTROL: no confirm opened and no holder read', async () => {
238+
mount('position');
239+
const sw = await screen.findByTestId('catalog-active-auditor');
240+
await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'false'));
241+
fireEvent.click(sw);
242+
await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'true'));
243+
expect(screen.queryByTestId('catalog-deactivate-dialog')).toBeNull();
244+
expect(posts).toEqual([{ url: '/api/v1/security/_activation/position/auditor', body: '{"enabled":true}' }]);
245+
expect(adapter.find).not.toHaveBeenCalled();
246+
expect(toasts.success).toHaveBeenCalledWith(
247+
tFormat('engine.catalog.active.switchedOn', 'en-US', { name: 'Auditor' }),
248+
);
249+
});
250+
251+
it('on the Permission Sets list, writes at once and toasts', async () => {
252+
ledgerRows = [{ metadata_type: 'permission', name: 'qa_fls_rep', active: false }];
253+
mount('permission');
254+
const sw = await screen.findByTestId('catalog-active-qa_fls_rep');
255+
await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'false'));
256+
fireEvent.click(sw);
257+
await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'true'));
258+
expect(screen.queryByTestId('catalog-deactivate-dialog')).toBeNull();
259+
expect(posts).toEqual([{ url: '/api/v1/security/_activation/permission/qa_fls_rep', body: '{"enabled":true}' }]);
260+
expect(toasts.success).toHaveBeenCalledWith(
261+
tFormat('engine.catalog.active.switchedOn', 'en-US', { name: 'QA FLS Rep' }),
262+
);
263+
});
264+
});
265+
266+
describe('the count, read the way the holders sections read it (objectui#13338)', () => {
267+
it('one holder is said in the singular', async () => {
268+
mount('permission');
269+
await askToSwitchOff('qa_fls_rep');
270+
expect(screen.getByTestId('catalog-deactivate-impact')).toHaveTextContent(
271+
t('engine.catalog.deactivate.holdersOne', 'en-US'),
272+
);
273+
});
274+
275+
it('a set bound to the everyone anchor is said to reach every signed-in member', async () => {
276+
mount('permission');
277+
await askToSwitchOff('member_default');
278+
expect(screen.getByTestId('catalog-deactivate-impact')).toHaveTextContent(
279+
t('engine.catalog.deactivate.everyone', 'en-US'),
280+
);
281+
});
282+
283+
it('reads the grants by the set name and the position holders by position name', async () => {
284+
mount('permission');
285+
await askToSwitchOff('sales_rep');
286+
expect(adapter.find).toHaveBeenCalledWith('sys_user_permission_set', {
287+
$filter: { permission_set: 'sales_rep' },
288+
$top: 1000,
289+
});
290+
expect(adapter.find).toHaveBeenCalledWith('sys_user_position', {
291+
$filter: { position: { $in: ['sales_manager'] } },
292+
$top: 1000,
293+
});
294+
});
295+
});

‎packages/app-shell/src/views/metadata-admin/ResourceListPage.environmentScope-7611.test.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,12 @@ describe('the active switch (the activation ledger, through its door)', () => {
227227
doorAnswer = { status: 403, body: { success: false, error: { code: 'PERMISSION_DENIED', message: refusal, httpStatus: 403 } } };
228228
mount('?scope=environment');
229229
fireEvent.click(await screen.findByTestId('catalog-active-admin_full_access'));
230+
// Switching off asks first (objectui#13338); this file mounts no data
231+
// source, so the confirm says the count could not be read and still lets
232+
// the admin go ahead.
233+
const confirm = await screen.findByTestId('catalog-deactivate-confirm');
234+
await waitFor(() => expect(confirm).not.toBeDisabled());
235+
fireEvent.click(confirm);
230236
expect(
231237
await screen.findByText(tFormat('engine.catalog.active.failed', 'en-US', { message: refusal })),
232238
).toBeInTheDocument();

‎packages/app-shell/src/views/metadata-admin/ResourceListPage.positionGrants-25333.test.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,13 @@ describe('the whole row opens the position (objectstack#25333)', () => {
194194
const sw = await screen.findByTestId('catalog-active-sales_manager');
195195
await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'true'));
196196
fireEvent.click(sw);
197+
// Switching off asks first (objectui#13338); the confirm opens on the
198+
// list, not on the position.
199+
expect(await screen.findByTestId('catalog-deactivate-dialog')).toBeInTheDocument();
200+
expect(screen.queryByTestId('where')).toBeNull();
201+
const confirm = screen.getByTestId('catalog-deactivate-confirm');
202+
await waitFor(() => expect(confirm).not.toBeDisabled());
203+
fireEvent.click(confirm);
197204
await waitFor(() => expect(requests.some((r) => r.method === 'POST')).toBe(true));
198205
expect(screen.queryByTestId('where')).toBeNull();
199206

0 commit comments

Comments
 (0)