@nocobase/app-template-default 1.0.0-beta.75 → 1.0.0-beta.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.agents/rules/build-and-deploy.md +4 -0
  2. package/.agents/rules/components.md +3 -1
  3. package/README.MD +2 -2
  4. package/client/components/data-table/data-table.tsx +2 -0
  5. package/client/components/data-table/pagination.tsx +6 -6
  6. package/client/components/default-access-editor.tsx +79 -52
  7. package/client/components/flow-diagram.tsx +645 -83
  8. package/client/components/flow-layout.ts +117 -7
  9. package/client/components/lifecycle-diagram.tsx +175 -37
  10. package/client/components/long-text.tsx +200 -0
  11. package/client/components/permission-editor.tsx +534 -341
  12. package/client/components/record-link.tsx +20 -0
  13. package/client/components/required-role-setup.tsx +93 -0
  14. package/client/components/role-list.tsx +21 -5
  15. package/client/components/setup-readiness-state.ts +135 -0
  16. package/client/components/setup-readiness.tsx +91 -0
  17. package/client/components/ui/combobox.tsx +300 -0
  18. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  19. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
  20. package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
  21. package/client/hooks/use-table-url-state.ts +93 -0
  22. package/client/lib/required-role-setup.ts +98 -0
  23. package/client/lib/return-to.ts +19 -0
  24. package/client/locales/system/en-US.ts +10 -0
  25. package/client/locales/system/settings-en-US.ts +51 -4
  26. package/client/locales/system/settings-zh-CN.ts +46 -5
  27. package/client/locales/system/zh-CN.ts +10 -0
  28. package/client/pages/settings/default-access-form.tsx +14 -2
  29. package/client/pages/settings/default-access-model.ts +6 -2
  30. package/client/pages/settings/default-access.tsx +40 -8
  31. package/client/pages/settings/page-permissions.ts +166 -1
  32. package/client/pages/settings/permission-filters.tsx +9 -2
  33. package/client/pages/settings/permission-workspace.ts +330 -0
  34. package/client/pages/settings/permissions-model.ts +62 -53
  35. package/client/pages/settings/role-form.tsx +31 -95
  36. package/client/pages/settings/roles.tsx +44 -6
  37. package/client/pages/settings/shared.tsx +15 -13
  38. package/client/pages/settings/user-detail.tsx +15 -259
  39. package/client/pages/settings/user-form.tsx +299 -147
  40. package/client/pages/settings/users.tsx +211 -123
  41. package/config.example.yml +6 -5
  42. package/package.json +17 -17
  43. package/server/config/app.ts +1 -1
  44. package/server/config/session.ts +7 -5
  45. package/tests/components/default-access-editor.test.tsx +130 -0
  46. package/tests/components/default-access.test.tsx +141 -2
  47. package/tests/components/permission-editor.test.tsx +613 -572
  48. package/tests/components/permission-filter-binding.test.tsx +20 -0
  49. package/tests/components/settings-continuity.test.tsx +426 -0
  50. package/tests/components/settings-feedback.test.tsx +304 -0
  51. package/tests/components/settings.test.tsx +34 -9
  52. package/tests/components/setup-readiness.test.tsx +370 -0
  53. package/tests/components/table-url-state.test.tsx +95 -0
  54. package/tests/components/user-drawer.test.tsx +526 -0
  55. package/tests/components/user-permissions.test.tsx +47 -13
  56. package/tests/logic/config.test.ts +3 -1
  57. package/tests/logic/page-permission-navigation.test.ts +394 -0
  58. package/tests/logic/permission-workspace.test.ts +570 -0
  59. package/tests/logic/return-to.test.ts +19 -0
  60. package/tests/logic/session-isolation.test.ts +250 -0
  61. package/tsconfig.json +1 -0
@@ -1,22 +1,35 @@
1
1
  import { useState } from 'react';
2
- import { render, screen } from '@testing-library/react';
2
+ import { fireEvent, render, screen, within } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { describe, expect, it, vi } from 'vitest';
5
- import { PermissionEditor } from '../../client/components/permission-editor.js';
5
+ import {
6
+ PermissionEditor,
7
+ type PermissionEditorSection,
8
+ } from '../../client/components/permission-editor.js';
9
+
6
10
  const sections = [
7
11
  {
8
12
  id: 'business',
9
13
  title: 'Business actions',
14
+ description: 'Choose what staff can do.',
10
15
  groups: [
11
16
  {
12
17
  id: 'orders',
13
18
  title: 'Orders',
19
+ hint: 'Order processing.',
14
20
  rows: [
15
- { id: 'read-orders', label: 'Read orders', enabled: true },
16
21
  {
17
- id: 'edit-orders',
18
- label: 'Edit orders',
19
- hint: 'Change the owner',
22
+ id: 'approve',
23
+ label: 'Approve order',
24
+ actionKey: 'approve',
25
+ hint: 'Confirm the order and reserve stock.',
26
+ enabled: true,
27
+ },
28
+ {
29
+ id: 'withdraw',
30
+ label: 'Withdraw order',
31
+ actionKey: 'withdraw',
32
+ hint: 'Withdraw an unprocessed order.',
20
33
  enabled: false,
21
34
  },
22
35
  ],
@@ -24,78 +37,182 @@ const sections = [
24
37
  ],
25
38
  },
26
39
  {
27
- id: 'settings',
28
- title: 'System settings',
40
+ id: 'configuration',
41
+ title: 'Application configuration',
29
42
  groups: [
30
43
  {
31
- id: 'users',
32
- title: 'Users',
33
- rows: [{ id: 'manage-users', label: 'Manage users', enabled: false }],
44
+ id: 'numbering',
45
+ title: 'Order numbering',
46
+ rows: [
47
+ { id: 'configure', label: 'Configure numbering', enabled: false },
48
+ ],
34
49
  },
35
50
  ],
36
51
  },
37
52
  ];
38
- describe('permission workspace', () => {
39
- it('filters across categories without changing hidden permissions and reports only explicit edits', async () => {
40
- const user = userEvent.setup();
41
- const enabled = vi.fn();
53
+
54
+ describe('application-owned permission workspace', () => {
55
+ it('renders caller order and custom actions without a built-in catalog or CRUD controls', () => {
42
56
  render(
43
57
  <PermissionEditor
44
58
  sections={sections}
45
- onEnabledChange={enabled}
59
+ onEnabledChange={vi.fn()}
46
60
  onLevelChange={vi.fn()}
47
61
  />,
48
62
  );
63
+ const checkboxes = screen
64
+ .getAllByRole('checkbox')
65
+ .filter(
66
+ (item) =>
67
+ item.getAttribute('aria-label') !== 'Enabled only' &&
68
+ item.id !== screen.getByRole('checkbox', { name: 'Enabled only' }).id,
69
+ );
70
+ expect(checkboxes.map((item) => item.textContent)).toHaveLength(2);
71
+ expect(checkboxes[0]).toHaveAccessibleName('Approve order');
72
+ expect(checkboxes[1]).toHaveAccessibleName('Withdraw order');
49
73
  expect(
50
- screen.queryByRole('checkbox', { name: 'Manage users' }),
74
+ screen.queryByRole('checkbox', { name: 'Configure numbering' }),
51
75
  ).not.toBeInTheDocument();
52
- await user.click(screen.getByRole('button', { name: /System settings/ }));
53
76
  expect(
54
- screen.getByRole('checkbox', { name: 'Manage users' }),
55
- ).not.toBeChecked();
56
- await user.type(screen.getByRole('textbox'), 'owner');
77
+ screen.getByRole('navigation', { name: 'Permission categories' }),
78
+ ).toBeVisible();
57
79
  expect(
58
- screen.getByRole('checkbox', { name: /Edit orders/ }),
59
- ).not.toBeChecked();
60
- expect(enabled).not.toHaveBeenCalled();
61
- await user.click(screen.getByRole('checkbox', { name: /Edit orders/ }));
62
- expect(enabled).toHaveBeenCalledExactlyOnceWith('edit-orders', true);
63
- await user.click(screen.getByRole('button', { name: 'Clear filters' }));
64
- expect(
65
- screen.getByRole('checkbox', { name: 'Manage users' }),
66
- ).not.toBeChecked();
80
+ screen.getByText('Confirm the order and reserve stock.'),
81
+ ).toBeVisible();
82
+ expect(screen.getByText('Order processing.')).toBeVisible();
83
+ expect(screen.queryByRole('switch')).not.toBeInTheDocument();
84
+ expect(screen.queryByText('System and tools')).not.toBeInTheDocument();
85
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
86
+ });
87
+
88
+ it('renders a meaningful empty state without adding permissions', () => {
89
+ render(
90
+ <PermissionEditor
91
+ sections={[]}
92
+ onEnabledChange={vi.fn()}
93
+ onLevelChange={vi.fn()}
94
+ />,
95
+ );
96
+ expect(screen.getByRole('status')).toHaveTextContent(
97
+ 'No permissions to configure',
98
+ );
99
+ expect(screen.getByRole('status')).toHaveTextContent(
100
+ 'Ask the application developer',
101
+ );
102
+ expect(screen.getAllByRole('checkbox')).toHaveLength(1);
103
+ expect(screen.queryByRole('heading', { level: 2 })).not.toBeInTheDocument();
67
104
  });
68
- it('keeps counts stable, distinguishes no results, and permits searching a readonly workspace', async () => {
105
+
106
+ it('filters globally by descriptions and parent names without changing the draft', async () => {
69
107
  const user = userEvent.setup();
70
- const enabled = vi.fn();
108
+ const change = vi.fn();
71
109
  render(
72
110
  <PermissionEditor
73
111
  sections={sections}
74
- readOnly
75
- onEnabledChange={enabled}
112
+ onEnabledChange={change}
76
113
  onLevelChange={vi.fn()}
77
114
  />,
78
115
  );
79
- await user.type(screen.getByRole('textbox'), 'not found');
116
+ await user.type(screen.getByRole('textbox'), 'reserve stock');
117
+ expect(
118
+ screen.getByRole('checkbox', { name: 'Approve order' }),
119
+ ).toBeChecked();
120
+ expect(
121
+ screen.queryByRole('checkbox', { name: 'Withdraw order' }),
122
+ ).not.toBeInTheDocument();
123
+ expect(change).not.toHaveBeenCalled();
124
+ await user.clear(screen.getByRole('textbox'));
125
+ await user.type(screen.getByRole('textbox'), 'Order processing');
126
+ expect(
127
+ screen.getByRole('checkbox', { name: 'Withdraw order' }),
128
+ ).toBeVisible();
129
+ await user.clear(screen.getByRole('textbox'));
130
+ await user.type(screen.getByRole('textbox'), 'missing');
80
131
  expect(screen.getByRole('status')).toHaveTextContent(
81
132
  'No matching permissions',
82
133
  );
134
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }));
135
+ expect(screen.getByRole('textbox')).toHaveFocus();
83
136
  expect(
84
- screen.getByRole('button', { name: /Business actions/ }),
85
- ).toHaveTextContent('Business actions');
86
- expect(screen.queryByText('1/2')).not.toBeInTheDocument();
87
- await user.clear(screen.getByRole('textbox'));
137
+ screen.getByRole('checkbox', { name: 'Withdraw order' }),
138
+ ).not.toBeChecked();
139
+ expect(change).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it('keeps the original category navigation and restores the selected category after global search', async () => {
143
+ const user = userEvent.setup();
144
+ const change = vi.fn();
145
+ render(
146
+ <PermissionEditor
147
+ sections={sections}
148
+ onEnabledChange={change}
149
+ onLevelChange={vi.fn()}
150
+ />,
151
+ );
152
+ const category = screen.getByRole('button', {
153
+ name: 'Application configuration',
154
+ });
155
+ await user.click(category);
156
+ expect(category).toHaveAttribute('aria-current', 'page');
88
157
  expect(
89
- screen.getByRole('checkbox', { name: 'Read orders' }),
90
- ).toHaveAttribute('aria-disabled', 'true');
91
- await user.click(screen.getByRole('checkbox', { name: 'Read orders' }));
92
- expect(enabled).not.toHaveBeenCalled();
158
+ screen.getByRole('checkbox', { name: 'Configure numbering' }),
159
+ ).toBeVisible();
160
+ expect(
161
+ screen.queryByRole('checkbox', { name: 'Approve order' }),
162
+ ).not.toBeInTheDocument();
163
+ await user.type(screen.getByRole('textbox'), 'reserve stock');
164
+ expect(
165
+ screen.getByRole('checkbox', { name: 'Approve order' }),
166
+ ).toBeVisible();
167
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }));
168
+ expect(
169
+ screen.getByRole('checkbox', { name: 'Configure numbering' }),
170
+ ).toBeVisible();
171
+ expect(category).toHaveAttribute('aria-current', 'page');
172
+ await user.type(screen.getByRole('textbox'), 'missing');
173
+ await user.click(screen.getByRole('button', { name: 'Business actions' }));
174
+ expect(screen.getByRole('textbox')).toHaveValue('');
175
+ expect(
176
+ screen.getByRole('checkbox', { name: 'Approve order' }),
177
+ ).toBeVisible();
178
+ expect(change).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it('supports normal typing, middle edits and Chinese composition', async () => {
182
+ const user = userEvent.setup();
183
+ render(
184
+ <PermissionEditor
185
+ sections={[
186
+ {
187
+ id: 'cn',
188
+ title: '审批',
189
+ groups: [
190
+ {
191
+ id: 'g',
192
+ rows: [{ id: 'a', label: '审批借用', enabled: true }],
193
+ },
194
+ ],
195
+ },
196
+ ]}
197
+ onEnabledChange={vi.fn()}
198
+ onLevelChange={vi.fn()}
199
+ />,
200
+ );
201
+ const input = screen.getByRole('textbox');
202
+ fireEvent.compositionStart(input);
203
+ fireEvent.change(input, { target: { value: '审批' } });
204
+ fireEvent.compositionEnd(input, { data: '审批' });
205
+ expect(input).toHaveValue('审批');
206
+ expect(screen.getByRole('checkbox', { name: '审批借用' })).toBeVisible();
207
+ await user.clear(input);
208
+ await user.type(input, '借用');
209
+ await user.keyboard('{Home}审批');
210
+ expect(input).toHaveValue('审批借用');
93
211
  });
94
- });
95
212
 
96
- describe('compact resource actions', () => {
97
- it('restores stable focus when enabled filtering removes the action or group', async () => {
213
+ it('emits only explicit changes and restores focus when an enabled-only result disappears', async () => {
98
214
  const user = userEvent.setup();
215
+ const batch = vi.fn();
99
216
  function Draft() {
100
217
  const [enabled, setEnabled] = useState(true);
101
218
  return (
@@ -105,595 +222,519 @@ describe('compact resource actions', () => {
105
222
  id: 's',
106
223
  title: 'Section',
107
224
  groups: [
108
- {
109
- id: 'g',
110
- title: 'Group',
111
- rows: [
112
- { id: 'a', label: 'Action', enabled },
113
- {
114
- id: 'hidden',
115
- label: 'Hidden',
116
- enabled: false,
117
- toggleable: false,
118
- },
119
- ],
120
- },
225
+ { id: 'g', rows: [{ id: 'a', label: 'Approve', enabled }] },
121
226
  ],
122
227
  },
123
228
  ]}
124
- onEnabledChange={(_, on) => setEnabled(on)}
125
- onEnabledBatchChange={(changes) => setEnabled(changes[0]!.enabled)}
229
+ onEnabledChange={(_, value) => setEnabled(value)}
230
+ onEnabledBatchChange={batch}
126
231
  onLevelChange={vi.fn()}
127
232
  />
128
233
  );
129
234
  }
130
235
  render(<Draft />);
131
- await user.click(screen.getByRole('switch', { name: 'Enabled only' }));
132
- await user.click(screen.getByRole('checkbox', { name: 'Action' }));
236
+ await user.click(screen.getByRole('checkbox', { name: 'Enabled only' }));
237
+ await user.click(screen.getByRole('checkbox', { name: 'Approve' }));
133
238
  expect(screen.getByRole('textbox')).toHaveFocus();
134
239
  expect(screen.getByRole('status')).toHaveTextContent(
135
240
  'No matching permissions',
136
241
  );
242
+ expect(batch).not.toHaveBeenCalled();
137
243
  });
138
- it('keeps scopes attached to actions, readonly scopes disabled and control ids instance-local', async () => {
139
- const scoped = [
140
- {
141
- id: 's',
142
- title: 'Section',
143
- groups: [
244
+
245
+ it('keeps readonly and application-managed checkboxes disabled with visible explanations', async () => {
246
+ const change = vi.fn();
247
+ const view = render(
248
+ <PermissionEditor
249
+ sections={sections}
250
+ readOnly
251
+ onEnabledChange={change}
252
+ onLevelChange={vi.fn()}
253
+ />,
254
+ );
255
+ const action = screen.getByRole('checkbox', { name: 'Approve order' });
256
+ expect(action).toHaveAttribute('aria-disabled', 'true');
257
+ expect(action).toHaveAccessibleDescription(
258
+ /These permissions are read-only/,
259
+ );
260
+ await userEvent.click(action);
261
+ expect(change).not.toHaveBeenCalled();
262
+ await userEvent.type(screen.getByRole('textbox'), 'numbering');
263
+ expect(
264
+ screen.getByRole('checkbox', { name: 'Configure numbering' }),
265
+ ).toBeVisible();
266
+ view.unmount();
267
+ render(
268
+ <PermissionEditor
269
+ sections={[
144
270
  {
145
- id: 'g',
146
- title: 'Orders',
147
- rows: [
271
+ id: 's',
272
+ title: 'Section',
273
+ groups: [
148
274
  {
149
- id: 'edit',
150
- label: 'Edit',
151
- enabled: true,
152
- scopes: [
275
+ id: 'g',
276
+ rows: [
153
277
  {
154
- id: 'edit-records',
155
- label: 'Records',
156
- level: 'mine',
157
- levels: [
158
- { value: 'mine', label: 'Mine' },
159
- { value: 'all', label: 'All' },
160
- ],
278
+ id: 'fixed',
279
+ label: 'Fixed action',
280
+ enabled: true,
281
+ toggleable: false,
161
282
  },
162
283
  ],
163
284
  },
164
285
  ],
165
286
  },
166
- ],
167
- },
168
- ];
169
- const change = vi.fn();
170
- const view = render(
171
- <PermissionEditor
172
- sections={scoped}
173
- onEnabledChange={vi.fn()}
174
- onLevelChange={change}
287
+ ]}
288
+ onEnabledChange={change}
289
+ onLevelChange={vi.fn()}
175
290
  />,
176
291
  );
177
292
  expect(
178
- view.container.querySelectorAll('[data-slot=permission-row]'),
179
- ).toHaveLength(1);
180
- await userEvent.click(
181
- screen.getByRole('combobox', { name: 'How far Edit · Records reaches' }),
182
- );
183
- await userEvent.click(await screen.findByRole('option', { name: 'All' }));
184
- expect(change).toHaveBeenCalledWith('edit-records', 'all');
185
- view.unmount();
186
- render(
187
- <>
188
- <PermissionEditor
189
- sections={scoped}
190
- readOnly
191
- onEnabledChange={vi.fn()}
192
- onLevelChange={vi.fn()}
193
- />
194
- <PermissionEditor
195
- sections={scoped}
196
- readOnly
197
- onEnabledChange={vi.fn()}
198
- onLevelChange={vi.fn()}
199
- />
200
- </>,
201
- );
202
- const boxes = screen.getAllByRole('checkbox', { name: 'Edit' });
203
- expect(boxes[0]!.id).not.toBe(boxes[1]!.id);
204
- expect(screen.getAllByRole('combobox')[0]).toBeDisabled();
293
+ screen.getByRole('checkbox', { name: 'Fixed action' }),
294
+ ).toHaveAttribute('aria-disabled', 'true');
205
295
  expect(
206
- screen.queryByRole('checkbox', { name: /Select visible/ }),
207
- ).not.toBeInTheDocument();
296
+ screen.getByText('This permission is managed by the application.'),
297
+ ).toBeVisible();
208
298
  });
209
- it('does not submit an enclosing role form while searching or switching categories', async () => {
299
+
300
+ it('uses instance-local control ids and does not submit the enclosing form', async () => {
301
+ const user = userEvent.setup();
210
302
  const submit = vi.fn((event) => event.preventDefault());
303
+ const change = vi.fn();
211
304
  render(
212
305
  <form onSubmit={submit}>
213
- <PermissionEditor
214
- sections={sections}
215
- onEnabledChange={vi.fn()}
216
- onLevelChange={vi.fn()}
217
- />
306
+ {[1, 2].map((key) => (
307
+ <PermissionEditor
308
+ key={key}
309
+ sections={sections}
310
+ onEnabledChange={change}
311
+ onLevelChange={vi.fn()}
312
+ />
313
+ ))}
218
314
  </form>,
219
315
  );
220
- await userEvent.click(
221
- screen.getByRole('button', { name: /System settings/ }),
222
- );
223
- await userEvent.type(screen.getByRole('textbox'), 'no match');
224
- await userEvent.click(
225
- screen.getByRole('button', { name: 'Clear filters' }),
316
+ const actions = screen.getAllByRole('checkbox', { name: 'Withdraw order' });
317
+ expect(actions[0]!.id).not.toBe(actions[1]!.id);
318
+ await user.click(actions[0]!);
319
+ expect(change).toHaveBeenCalledExactlyOnceWith('withdraw', true);
320
+ actions[1]!.focus();
321
+ await user.keyboard(' ');
322
+ expect(change).toHaveBeenCalledTimes(2);
323
+ expect(change).toHaveBeenLastCalledWith('withdraw', true);
324
+ screen.getAllByRole('checkbox', { name: 'Enabled only' })[0]!.focus();
325
+ await user.keyboard(' ');
326
+ expect(change).toHaveBeenCalledTimes(2);
327
+ await user.type(screen.getAllByRole('textbox')[0]!, 'missing');
328
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }));
329
+ await user.click(
330
+ screen.getAllByRole('button', { name: 'Application configuration' })[0]!,
226
331
  );
332
+ screen.getByRole('checkbox', { name: 'Configure numbering' }).focus();
333
+ await user.keyboard(' ');
334
+ expect(change).toHaveBeenLastCalledWith('configure', true);
227
335
  expect(submit).not.toHaveBeenCalled();
228
336
  });
229
- });
230
337
 
231
- it('shows a single page access checkbox without a duplicate access action or resource counter', async () => {
232
- const onEnabledChange = vi.fn();
233
- const onEnabledBatchChange = vi.fn();
234
- render(
235
- <PermissionEditor
236
- sections={[
237
- {
238
- id: 'pages',
239
- title: 'Page access',
240
- groups: [
241
- {
242
- id: 'expenses',
243
- title: 'Expense registration',
244
- accessOnly: true,
245
- rows: [
246
- { id: 'page-expenses-access', label: 'Access', enabled: true },
247
- ],
248
- },
249
- ],
250
- },
251
- ]}
252
- onEnabledChange={onEnabledChange}
253
- onEnabledBatchChange={onEnabledBatchChange}
254
- onLevelChange={vi.fn()}
255
- />,
256
- );
257
- expect(screen.getAllByRole('checkbox')).toHaveLength(1);
258
- expect(screen.queryByText('Access')).not.toBeInTheDocument();
259
- expect(screen.queryByText('1 of 1 enabled')).not.toBeInTheDocument();
260
- await userEvent.click(
261
- screen.getByRole('checkbox', { name: 'Expense registration' }),
262
- );
263
- expect(onEnabledChange).toHaveBeenCalledExactlyOnceWith(
264
- 'page-expenses-access',
265
- false,
266
- );
267
- expect(onEnabledBatchChange).not.toHaveBeenCalled();
338
+ it('uses a page title once for a single access action', () => {
339
+ render(
340
+ <PermissionEditor
341
+ sections={[
342
+ {
343
+ id: 'pages',
344
+ title: 'Pages',
345
+ groups: [
346
+ {
347
+ id: 'p',
348
+ title: 'Borrowing',
349
+ accessOnly: true,
350
+ rows: [{ id: 'page-access', label: 'Access', enabled: true }],
351
+ },
352
+ ],
353
+ },
354
+ ]}
355
+ onEnabledChange={vi.fn()}
356
+ onLevelChange={vi.fn()}
357
+ />,
358
+ );
359
+ expect(screen.getByRole('checkbox', { name: 'Borrowing' })).toBeChecked();
360
+ expect(screen.queryByText('Access')).not.toBeInTheDocument();
361
+ });
268
362
  });
269
363
 
270
- it('reveals multiple scopes without changing their independent saved selections', async () => {
271
- const change = vi.fn();
272
- render(
273
- <PermissionEditor
274
- sections={[
364
+ describe('action-owned data ranges', () => {
365
+ const scoped = [
366
+ {
367
+ id: 's',
368
+ title: 'Business',
369
+ groups: [
275
370
  {
276
- id: 's',
277
- title: 'Business',
278
- groups: [
371
+ id: 'g',
372
+ title: 'Borrowing',
373
+ rows: [
279
374
  {
280
- id: 'g',
281
- title: 'Expenses',
282
- rows: [
283
- {
284
- id: 'view',
285
- label: 'View',
286
- enabled: true,
287
- scopes: ['Records', 'Attachments'].map((label) => ({
288
- id: label.toLowerCase(),
289
- label,
290
- level: 'own',
291
- levels: [
292
- { value: 'own', label: 'Own' },
293
- { value: 'all', label: 'All' },
294
- ],
295
- })),
296
- },
297
- ],
375
+ id: 'approve',
376
+ label: 'Approve request',
377
+ enabled: true,
378
+ scopes: ['Requests', 'Devices'].map((label) => ({
379
+ id: label.toLowerCase(),
380
+ label,
381
+ level: 'own',
382
+ hint: 'This role adds no records; other roles and rules also apply.',
383
+ levels: [
384
+ { value: 'own', label: 'Own' },
385
+ { value: 'all', label: 'All' },
386
+ ],
387
+ })),
298
388
  },
299
389
  ],
300
390
  },
301
- ]}
302
- onEnabledChange={vi.fn()}
303
- onLevelChange={change}
304
- />,
305
- );
306
- expect(screen.getByText('Records')).not.toBeVisible();
307
- await userEvent.click(screen.getByText('Data ranges (2)'));
308
- expect(change).not.toHaveBeenCalled();
309
- expect(screen.getByText('Records')).toBeVisible();
310
- expect(screen.getByText('Attachments')).toBeVisible();
311
- await userEvent.click(
312
- screen.getByRole('combobox', {
313
- name: 'How far View · Attachments reaches',
314
- }),
315
- );
316
- await userEvent.click(await screen.findByRole('option', { name: 'All' }));
317
- expect(change).toHaveBeenCalledExactlyOnceWith('attachments', 'all');
318
- });
391
+ ],
392
+ },
393
+ ];
319
394
 
320
- it('uses the category navigation without a duplicate heading and identifies filtered results', async () => {
321
- const batch = vi.fn();
322
- render(
323
- <PermissionEditor
324
- sections={[
325
- {
326
- id: 'users',
327
- title: 'Users',
328
- groups: [
329
- {
330
- id: 'users',
331
- title: 'Users',
332
- rows: [
333
- {
334
- id: 'read',
335
- label: 'View users',
336
- actionKey: 'read',
337
- enabled: true,
338
- },
339
- {
340
- id: 'edit',
341
- label: 'Edit users',
342
- actionKey: 'update',
343
- enabled: false,
344
- },
345
- ],
346
- },
347
- ],
348
- },
349
- ]}
350
- onEnabledChange={vi.fn()}
351
- onEnabledBatchChange={batch}
352
- onLevelChange={vi.fn()}
353
- />,
354
- );
355
- expect(
356
- screen.queryByRole('heading', { level: 2, name: 'Users' }),
357
- ).not.toBeInTheDocument();
358
- expect(screen.getByRole('region', { name: 'Users' })).toBeInTheDocument();
359
- await userEvent.type(screen.getByRole('textbox'), 'users');
360
- expect(
361
- screen.getByRole('heading', { level: 2, name: 'Users' }),
362
- ).toBeVisible();
363
- expect(batch).not.toHaveBeenCalled();
364
- expect(screen.queryByText('1/2')).not.toBeInTheDocument();
365
- expect(
366
- screen.queryByRole('checkbox', { name: /Select visible/ }),
367
- ).not.toBeInTheDocument();
368
- expect(
369
- screen.queryByText('All', { selector: 'span' }),
370
- ).not.toBeInTheDocument();
371
- await userEvent.click(screen.getByRole('checkbox', { name: 'Edit users' }));
372
- expect(batch).not.toHaveBeenCalled();
395
+ it('shows every named range directly and changes only its independently keyed value', async () => {
396
+ const change = vi.fn();
397
+ const enabled = vi.fn();
398
+ const view = render(
399
+ <PermissionEditor
400
+ sections={scoped}
401
+ onEnabledChange={enabled}
402
+ onLevelChange={change}
403
+ />,
404
+ );
405
+ expect(screen.getByText('Requests')).toBeVisible();
406
+ expect(screen.getByText('Devices')).toBeVisible();
407
+ expect(screen.getAllByRole('combobox')).toHaveLength(2);
408
+ expect(
409
+ screen.getByText(
410
+ 'This role adds no records; other roles and rules also apply.',
411
+ ),
412
+ ).toBeVisible();
413
+ const row = view.container.querySelector('[data-slot=permission-row]')!;
414
+ expect(
415
+ within(row as HTMLElement).getByRole('checkbox'),
416
+ ).toHaveAccessibleName('Approve request');
417
+ await userEvent.click(
418
+ screen.getByRole('combobox', {
419
+ name: 'How far Approve request · Devices reaches',
420
+ }),
421
+ );
422
+ await userEvent.click(await screen.findByRole('option', { name: 'All' }));
423
+ expect(change).toHaveBeenCalledExactlyOnceWith('devices', 'all');
424
+ expect(enabled).not.toHaveBeenCalled();
425
+ });
426
+
427
+ it('disables range controls in readonly mode and hides them only when the action is disabled', () => {
428
+ const view = render(
429
+ <PermissionEditor
430
+ sections={scoped}
431
+ readOnly
432
+ onEnabledChange={vi.fn()}
433
+ onLevelChange={vi.fn()}
434
+ />,
435
+ );
436
+ expect(
437
+ screen
438
+ .getAllByRole('combobox')
439
+ .every((node) => node.hasAttribute('disabled')),
440
+ ).toBe(true);
441
+ view.rerender(
442
+ <PermissionEditor
443
+ sections={[
444
+ {
445
+ ...scoped[0]!,
446
+ groups: [
447
+ {
448
+ ...scoped[0]!.groups[0]!,
449
+ rows: [{ ...scoped[0]!.groups[0]!.rows[0]!, enabled: false }],
450
+ },
451
+ ],
452
+ },
453
+ ]}
454
+ onEnabledChange={vi.fn()}
455
+ onLevelChange={vi.fn()}
456
+ />,
457
+ );
458
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
459
+ });
460
+
461
+ it('keeps help and parameter configuration separate from authorization toggles', async () => {
462
+ const enabled = vi.fn();
463
+ const changed = vi.fn();
464
+ const view = render(
465
+ <PermissionEditor
466
+ sections={[
467
+ {
468
+ id: 's',
469
+ title: 'Business',
470
+ groups: [
471
+ {
472
+ id: 'g',
473
+ rows: [
474
+ {
475
+ id: 'view',
476
+ label: 'View requests',
477
+ enabled: true,
478
+ scopes: [
479
+ {
480
+ id: 'records',
481
+ label: 'Requests',
482
+ level: '',
483
+ hint: 'Default access and sharing still apply.',
484
+ levels: [
485
+ { value: '', label: 'No additional record access' },
486
+ { value: 'all', label: 'All records' },
487
+ ],
488
+ configuration: (
489
+ <button type='button'>Configure filter</button>
490
+ ),
491
+ },
492
+ ],
493
+ },
494
+ ],
495
+ },
496
+ ],
497
+ },
498
+ ]}
499
+ onEnabledChange={enabled}
500
+ onLevelChange={changed}
501
+ />,
502
+ );
503
+ const help = screen.getByRole('button', {
504
+ name: 'About the data range for View requests · Requests',
505
+ });
506
+ await userEvent.click(help);
507
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(
508
+ 'Default access and sharing still apply.',
509
+ );
510
+ await userEvent.keyboard('{Escape}');
511
+ await userEvent.click(
512
+ screen.getByRole('button', { name: 'Configure filter' }),
513
+ );
514
+ expect(enabled).not.toHaveBeenCalled();
515
+ expect(changed).not.toHaveBeenCalled();
516
+ expect(
517
+ view.container.querySelectorAll('[data-slot=permission-row]'),
518
+ ).toHaveLength(1);
519
+ });
373
520
  });
374
521
 
375
- it('aligns semantic actions across resources without dropping special or filtered actions', async () => {
376
- const groups = [
522
+ const treeSection: PermissionEditorSection = {
523
+ id: 'pages',
524
+ title: 'Page access',
525
+ groups: [
377
526
  {
378
- id: 'one',
379
- title: 'One',
527
+ id: 'page-rows',
380
528
  rows: [
381
- {
382
- id: 'edit-one',
383
- actionKey: 'update',
384
- label: 'Edit one',
385
- enabled: true,
386
- },
387
- { id: 'read-one', actionKey: 'read', label: 'View one', enabled: true },
529
+ { id: 'catalog', label: 'Catalog', enabled: true },
530
+ { id: 'books', label: 'Books', enabled: true },
531
+ { id: 'review', label: 'Review loans', enabled: false },
532
+ { id: 'outside', label: 'Independent page', enabled: false },
388
533
  ],
389
534
  },
535
+ ],
536
+ navigation: [
390
537
  {
391
- id: 'two',
392
- title: 'Two',
393
- rows: [
394
- { id: 'read-two', actionKey: 'read', label: 'View two', enabled: true },
395
- {
396
- id: 'special',
397
- actionKey: 'export',
398
- label: 'Export two',
399
- enabled: false,
400
- },
401
- ],
402
- },
403
- ];
404
- const view = render(
405
- <PermissionEditor
406
- sections={[{ id: 'business', title: 'Business', groups }]}
407
- onEnabledChange={vi.fn()}
408
- onLevelChange={vi.fn()}
409
- />,
410
- );
411
- const column = (name: string) =>
412
- screen
413
- .getByRole('checkbox', { name })
414
- .closest('[data-slot=permission-row]')!
415
- .parentElement!.style.getPropertyValue('--permission-column');
416
- expect(column('View one')).toBe(column('View two'));
417
- expect(column('Edit one')).not.toBe(column('Export two'));
418
- expect(screen.getAllByRole('checkbox')).toHaveLength(4);
419
- await userEvent.type(screen.getByRole('textbox'), 'Export');
420
- expect(
421
- screen.getByRole('checkbox', { name: 'Export two' }),
422
- ).toBeInTheDocument();
423
- expect(column('Export two')).toBe('3');
424
- expect(
425
- view.container.querySelectorAll('[data-slot=permission-row]'),
426
- ).toHaveLength(1);
427
- });
428
-
429
- it('keeps scope captions consistent in a category with multiple data ranges, including when filtered', async () => {
430
- const level = [{ value: 'all', label: 'All records' }];
431
- render(
432
- <PermissionEditor
433
- sections={[
538
+ id: 'library-group',
539
+ label: 'Library',
540
+ children: [
434
541
  {
435
- id: 'business',
436
- title: 'Business',
437
- groups: [
542
+ id: 'catalog-node',
543
+ label: 'Catalog',
544
+ rowId: 'catalog',
545
+ children: [
546
+ { id: 'books-node', label: 'Books', rowId: 'books' },
438
547
  {
439
- id: 'quotes',
440
- title: 'Quotes',
441
- rows: [
442
- {
443
- id: 'view',
444
- label: 'View quotes',
445
- enabled: true,
446
- scopes: [
447
- {
448
- id: 'view-records',
449
- label: 'Visible quotes',
450
- level: 'all',
451
- levels: level,
452
- },
453
- ],
454
- },
455
- {
456
- id: 'submit',
457
- label: 'Submit quote',
458
- enabled: true,
459
- scopes: [
460
- {
461
- id: 'submit-projects',
462
- label: 'Linked projects',
463
- level: 'all',
464
- levels: level,
465
- },
466
- {
467
- id: 'submit-quotes',
468
- label: 'Allowed quotes',
469
- level: 'all',
470
- levels: level,
471
- },
472
- ],
473
- },
548
+ id: 'review-group',
549
+ label: 'Approvals',
550
+ children: [
551
+ { id: 'review-node', label: 'Review loans', rowId: 'review' },
474
552
  ],
475
553
  },
476
554
  ],
477
555
  },
478
- ]}
479
- onEnabledChange={vi.fn()}
480
- onLevelChange={vi.fn()}
481
- />,
482
- );
483
- expect(screen.getByText('Visible quotes')).toBeVisible();
484
- expect(
485
- screen.getByText(
486
- 'Linked projects: All records; Allowed quotes: All records',
487
- ),
488
- ).toBeVisible();
489
- await userEvent.click(screen.getByText('Data ranges (2)'));
490
- expect(screen.getByText('Linked projects')).toBeVisible();
491
- expect(screen.getByText('Allowed quotes')).toBeVisible();
492
- await userEvent.type(screen.getByRole('textbox'), 'View quotes');
493
- expect(screen.queryByText('Linked projects')).not.toBeInTheDocument();
494
- expect(screen.getByText('Visible quotes')).toBeVisible();
495
- });
556
+ ],
557
+ },
558
+ { id: 'empty', label: 'Unused group', children: [] },
559
+ { id: 'missing', label: 'Unknown page', rowId: 'unknown' },
560
+ { id: 'alias', label: 'Duplicate books', rowId: 'books' },
561
+ ],
562
+ };
496
563
 
497
- it('keeps administration actions ordered and independently editable with unrelated special actions', async () => {
498
- const enabled = vi.fn();
499
- render(
500
- <PermissionEditor
501
- sections={[
502
- {
503
- id: 'admin',
504
- title: 'Administration',
505
- groups: [
506
- {
507
- id: 'roles',
508
- title: 'Roles',
509
- rows: [
510
- {
511
- id: 'grants',
512
- actionKey: 'grants',
513
- label: 'Manage grants',
514
- enabled: false,
515
- },
516
- {
517
- id: 'delete',
518
- actionKey: 'delete',
519
- label: 'Delete',
520
- enabled: false,
521
- },
522
- { id: 'read', actionKey: 'read', label: 'View', enabled: true },
523
- {
524
- id: 'update',
525
- actionKey: 'update',
526
- label: 'Edit',
527
- enabled: false,
528
- },
529
- {
530
- id: 'create',
531
- actionKey: 'create',
532
- label: 'Create',
533
- enabled: false,
534
- },
535
- ],
536
- },
537
- {
538
- id: 'checker',
539
- title: 'Checker',
540
- rows: [
541
- {
542
- id: 'check',
543
- actionKey: 'check',
544
- label: 'Check permissions',
545
- enabled: false,
546
- },
547
- ],
548
- },
549
- {
550
- id: 'scheduler',
551
- title: 'Scheduler',
552
- rows: [
553
- {
554
- id: 'run',
555
- actionKey: 'run',
556
- label: 'Start and stop rules',
557
- enabled: false,
558
- },
559
- ],
560
- },
561
- ],
562
- },
563
- ]}
564
- onEnabledChange={enabled}
565
- onLevelChange={vi.fn()}
566
- />,
567
- );
568
- const controls = screen.getAllByRole('checkbox');
569
- for (const [index, name] of [
570
- 'View',
571
- 'Create',
572
- 'Edit',
573
- 'Delete',
574
- 'Manage grants',
575
- 'Check permissions',
576
- 'Start and stop rules',
577
- ].entries()) {
578
- expect(controls[index]).toHaveAccessibleName(name);
579
- }
580
- expect(
581
- screen.getByRole('checkbox', { name: 'View', exact: true }),
582
- ).toBeChecked();
583
- expect(enabled).not.toHaveBeenCalled();
584
- await userEvent.type(screen.getByRole('textbox'), 'Manage grants');
585
- expect(screen.getAllByRole('checkbox')).toHaveLength(1);
586
- await userEvent.click(
587
- screen.getByRole('checkbox', { name: 'Manage grants' }),
588
- );
589
- expect(enabled).toHaveBeenCalledExactlyOnceWith('grants', true);
590
- });
564
+ describe('application-owned page navigation', () => {
565
+ it('renders nested groups and independent parent/page grants without duplicates or invented checkboxes', () => {
566
+ const change = vi.fn();
567
+ render(
568
+ <PermissionEditor
569
+ sections={[treeSection]}
570
+ onEnabledChange={change}
571
+ onLevelChange={vi.fn()}
572
+ />,
573
+ );
574
+ expect(
575
+ screen.getByRole('button', { name: 'Collapse Library' }),
576
+ ).toHaveAttribute('aria-expanded', 'true');
577
+ expect(screen.getByRole('checkbox', { name: 'Catalog' })).toBeChecked();
578
+ expect(screen.getByRole('checkbox', { name: 'Books' })).toBeChecked();
579
+ expect(
580
+ screen.getByRole('checkbox', { name: 'Review loans' }),
581
+ ).not.toBeChecked();
582
+ expect(
583
+ screen.getByRole('checkbox', { name: 'Independent page' }),
584
+ ).toBeVisible();
585
+ expect(
586
+ screen.queryByRole('checkbox', { name: 'Library' }),
587
+ ).not.toBeInTheDocument();
588
+ expect(screen.queryByText('Unused group')).not.toBeInTheDocument();
589
+ expect(screen.queryByText('Unknown page')).not.toBeInTheDocument();
590
+ expect(screen.queryByText('Duplicate books')).not.toBeInTheDocument();
591
+ expect(screen.getAllByRole('checkbox', { name: 'Books' })).toHaveLength(1);
592
+ expect(change).not.toHaveBeenCalled();
593
+ });
591
594
 
592
- it('explains categories and allows individual review without bulk selection', async () => {
593
- const change = vi.fn();
594
- const batch = vi.fn();
595
- render(
596
- <PermissionEditor
597
- sections={[
598
- {
599
- id: 'accounts',
600
- title: 'Users and accounts',
601
- description: 'Review account permissions individually.',
602
- groups: [
603
- {
604
- id: 'security',
605
- title: 'Account security',
606
- rows: [
607
- {
608
- id: 'disable',
609
- label: 'Disable account',
610
- enabled: false,
611
- hint: 'Prevent further sign-in.',
612
- },
613
- { id: 'reset', label: 'Reset password', enabled: false },
614
- ],
615
- },
616
- ],
617
- },
618
- ]}
619
- onEnabledChange={change}
620
- onEnabledBatchChange={batch}
621
- onLevelChange={vi.fn()}
622
- />,
623
- );
624
- expect(
625
- screen.getByText('Review account permissions individually.'),
626
- ).toBeVisible();
627
- expect(screen.getAllByRole('checkbox')).toHaveLength(2);
628
- expect(
629
- screen.queryByText('All', { selector: 'span' }),
630
- ).not.toBeInTheDocument();
631
- expect(screen.getByText('Prevent further sign-in.')).toBeVisible();
632
- expect(change).not.toHaveBeenCalled();
633
- expect(batch).not.toHaveBeenCalled();
634
- await userEvent.click(
635
- screen.getByRole('checkbox', { name: 'Disable account' }),
636
- );
637
- expect(change).toHaveBeenCalledExactlyOnceWith('disable', true);
638
- });
595
+ it('separates keyboard disclosure, individual grants and the host form submit', async () => {
596
+ const user = userEvent.setup();
597
+ const change = vi.fn();
598
+ const submit = vi.fn((event: React.FormEvent) => event.preventDefault());
599
+ render(
600
+ <form onSubmit={submit}>
601
+ <PermissionEditor
602
+ sections={[treeSection]}
603
+ onEnabledChange={change}
604
+ onLevelChange={vi.fn()}
605
+ />
606
+ </form>,
607
+ );
608
+ const collapse = screen.getByRole('button', { name: 'Collapse Catalog' });
609
+ collapse.focus();
610
+ await user.keyboard('{Enter}');
611
+ expect(
612
+ screen.queryByRole('checkbox', { name: 'Books' }),
613
+ ).not.toBeInTheDocument();
614
+ expect(screen.getByRole('checkbox', { name: 'Catalog' })).toBeChecked();
615
+ expect(change).not.toHaveBeenCalled();
616
+ await user.keyboard(' ');
617
+ expect(screen.getByRole('checkbox', { name: 'Books' })).toBeChecked();
618
+ await user.click(screen.getByRole('checkbox', { name: 'Catalog' }));
619
+ expect(change).toHaveBeenCalledExactlyOnceWith('catalog', false);
620
+ expect(screen.getByRole('checkbox', { name: 'Books' })).toBeChecked();
621
+ expect(submit).not.toHaveBeenCalled();
622
+ });
639
623
 
640
- it('shows scope explanations on focus or tap without changing the permission draft', async () => {
641
- const user = userEvent.setup();
642
- const enabled = vi.fn();
643
- const changed = vi.fn();
644
- render(
645
- <PermissionEditor
646
- sections={[
647
- {
648
- id: 'orders',
649
- title: 'Orders',
650
- groups: [
624
+ it('searches ancestor names, preserves paths and restores collapse state after Clear', async () => {
625
+ const user = userEvent.setup();
626
+ const change = vi.fn();
627
+ render(
628
+ <PermissionEditor
629
+ sections={[treeSection]}
630
+ onEnabledChange={change}
631
+ onLevelChange={vi.fn()}
632
+ />,
633
+ );
634
+ await user.click(screen.getByRole('button', { name: 'Collapse Catalog' }));
635
+ await user.type(screen.getByRole('textbox'), 'Library');
636
+ expect(screen.getByRole('checkbox', { name: 'Books' })).toBeVisible();
637
+ expect(
638
+ screen.getByRole('checkbox', { name: 'Review loans' }),
639
+ ).toBeVisible();
640
+ expect(
641
+ screen.queryByRole('checkbox', { name: 'Independent page' }),
642
+ ).not.toBeInTheDocument();
643
+ await user.clear(screen.getByRole('textbox'));
644
+ await user.type(screen.getByRole('textbox'), 'Review loans');
645
+ expect(screen.getByText('Library')).toBeVisible();
646
+ expect(screen.getByText('Approvals')).toBeVisible();
647
+ expect(
648
+ screen.queryByRole('checkbox', { name: 'Books' }),
649
+ ).not.toBeInTheDocument();
650
+ expect(
651
+ screen.queryByRole('checkbox', { name: 'Catalog' }),
652
+ ).not.toBeInTheDocument();
653
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }));
654
+ expect(screen.getByRole('textbox')).toHaveFocus();
655
+ expect(
656
+ screen.queryByRole('checkbox', { name: 'Review loans' }),
657
+ ).not.toBeInTheDocument();
658
+ expect(
659
+ screen.getByRole('button', { name: 'Expand Catalog' }),
660
+ ).toHaveAttribute('aria-expanded', 'false');
661
+ expect(change).not.toHaveBeenCalled();
662
+ });
663
+
664
+ it('retains enabled-only ancestor context and moves focus before a disabled row disappears', async () => {
665
+ const user = userEvent.setup();
666
+ function ControlledTree() {
667
+ const [enabled, setEnabled] = useState(true);
668
+ return (
669
+ <PermissionEditor
670
+ sections={[
651
671
  {
652
- id: 'orders',
653
- title: 'Orders',
654
- rows: [
672
+ ...treeSection,
673
+ groups: [
655
674
  {
656
- id: 'view',
657
- label: 'View orders',
658
- enabled: true,
659
- scopes: [
660
- {
661
- id: 'scope',
662
- label: 'Orders',
663
- level: '',
664
- hint: 'Default access and sharing still apply.',
665
- levels: [
666
- { value: '', label: 'No additional record access' },
667
- { value: 'all', label: 'All records' },
668
- ],
669
- },
670
- ],
675
+ id: 'books',
676
+ rows: [{ id: 'books', label: 'Books', enabled }],
671
677
  },
672
678
  ],
673
679
  },
674
- ],
675
- },
676
- ]}
677
- onEnabledChange={enabled}
678
- onLevelChange={changed}
679
- />,
680
- );
681
- const help = screen.getByRole('button', {
682
- name: 'About the data range for View orders · Orders',
680
+ ]}
681
+ onEnabledChange={(_, value) => setEnabled(value)}
682
+ onLevelChange={vi.fn()}
683
+ />
684
+ );
685
+ }
686
+ render(<ControlledTree />);
687
+ await user.click(screen.getByRole('checkbox', { name: 'Enabled only' }));
688
+ expect(screen.getByText('Library')).toBeVisible();
689
+ expect(screen.getByText('Catalog')).toBeVisible();
690
+ expect(screen.queryByText('Approvals')).not.toBeInTheDocument();
691
+ await user.click(screen.getByRole('checkbox', { name: 'Books' }));
692
+ expect(screen.getByRole('textbox')).toHaveFocus();
693
+ expect(screen.getByRole('status')).toHaveTextContent(
694
+ 'No matching permissions',
695
+ );
696
+ });
697
+
698
+ it('keeps a single-level menu flat and allows read-only disclosure without editing grants', async () => {
699
+ const user = userEvent.setup();
700
+ const change = vi.fn();
701
+ const { rerender } = render(
702
+ <PermissionEditor
703
+ sections={[
704
+ {
705
+ ...treeSection,
706
+ navigation: [{ id: 'books', label: 'Books', rowId: 'books' }],
707
+ },
708
+ ]}
709
+ onEnabledChange={change}
710
+ onLevelChange={vi.fn()}
711
+ />,
712
+ );
713
+ expect(screen.getAllByRole('list')).toHaveLength(1);
714
+ expect(
715
+ screen.queryByRole('button', { name: /Collapse|Expand/ }),
716
+ ).not.toBeInTheDocument();
717
+ rerender(
718
+ <PermissionEditor
719
+ sections={[treeSection]}
720
+ readOnly
721
+ onEnabledChange={change}
722
+ onLevelChange={vi.fn()}
723
+ />,
724
+ );
725
+ expect(screen.getByRole('checkbox', { name: 'Books' })).toHaveAttribute(
726
+ 'aria-disabled',
727
+ 'true',
728
+ );
729
+ await user.click(screen.getByRole('checkbox', { name: 'Books' }));
730
+ expect(change).not.toHaveBeenCalled();
731
+ expect(
732
+ screen.getByRole('checkbox', { name: 'Books' }),
733
+ ).toHaveAccessibleDescription('These permissions are read-only.');
734
+ await user.click(screen.getByRole('button', { name: 'Collapse Library' }));
735
+ expect(
736
+ screen.getByRole('button', { name: 'Expand Library' }),
737
+ ).toBeEnabled();
738
+ expect(change).not.toHaveBeenCalled();
683
739
  });
684
- expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
685
- await user.click(help);
686
- expect(await screen.findByRole('tooltip')).toHaveTextContent(
687
- 'Default access and sharing still apply.',
688
- );
689
- await user.keyboard('{Escape}');
690
- expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
691
- await user.tab({ shift: true });
692
- await user.tab();
693
- expect(help).toHaveFocus();
694
- expect(await screen.findByRole('tooltip')).toHaveTextContent(
695
- 'Default access and sharing still apply.',
696
- );
697
- expect(enabled).not.toHaveBeenCalled();
698
- expect(changed).not.toHaveBeenCalled();
699
740
  });