@nocobase/app-template-default 1.0.0-beta.68 → 1.0.0-beta.70

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 (76) hide show
  1. package/.agents/rules/components.md +5 -1
  2. package/.agents/rules/shell.md +3 -1
  3. package/AGENTS.md +7 -3
  4. package/Dockerfile.dockerignore +0 -1
  5. package/README.MD +3 -1
  6. package/client/app.ts +15 -0
  7. package/client/components/data-table/column-header.tsx +91 -0
  8. package/client/components/data-table/data-table.tsx +216 -0
  9. package/client/components/data-table/index.ts +13 -0
  10. package/client/components/data-table/pagination.tsx +165 -0
  11. package/client/components/data-table/view-options.tsx +77 -0
  12. package/client/components/flow-diagram-css.d.ts +2 -0
  13. package/client/components/flow-diagram.tsx +674 -0
  14. package/client/components/flow-layout.ts +250 -0
  15. package/client/components/lifecycle-diagram-model.ts +217 -0
  16. package/client/components/lifecycle-diagram.tsx +310 -0
  17. package/client/components/permission-editor.tsx +589 -0
  18. package/client/components/permission-filter.tsx +455 -0
  19. package/client/components/route-overlay.tsx +26 -13
  20. package/client/components/ui/checkbox.tsx +25 -0
  21. package/client/components/ui/sidebar.tsx +1 -4
  22. package/client/components/ui/switch.tsx +30 -0
  23. package/client/components/ui/table.tsx +113 -0
  24. package/client/extensions/nocobase-workflow-management/README.md +70 -0
  25. package/client/extensions/nocobase-workflow-management/definition-list.tsx +148 -0
  26. package/client/extensions/nocobase-workflow-management/execution-detail.tsx +230 -0
  27. package/client/extensions/nocobase-workflow-management/execution-list.tsx +175 -0
  28. package/client/extensions/nocobase-workflow-management/layout.tsx +95 -0
  29. package/client/layouts/components/app-sidebar.tsx +1 -1
  30. package/client/layouts/use-sidebar-preference.ts +62 -42
  31. package/client/locales/system/en-US.ts +29 -0
  32. package/client/locales/system/settings-en-US.ts +142 -0
  33. package/client/locales/system/settings-zh-CN.ts +136 -0
  34. package/client/locales/system/zh-CN.ts +29 -0
  35. package/client/pages/auth/auth-page.css +15 -0
  36. package/client/pages/auth/login.tsx +1 -1
  37. package/client/pages/auth/shared.tsx +3 -1
  38. package/client/pages/settings/hooks.tsx +137 -0
  39. package/client/pages/settings/page-permission-titles.ts +7 -0
  40. package/client/pages/settings/page-permissions.ts +42 -0
  41. package/client/pages/settings/permission-filters.tsx +119 -0
  42. package/client/pages/settings/permissions-model.ts +260 -0
  43. package/client/pages/settings/role-form.tsx +405 -0
  44. package/client/pages/settings/roles.tsx +163 -0
  45. package/client/pages/settings/routes.ts +79 -0
  46. package/client/pages/settings/shared.tsx +119 -0
  47. package/client/pages/settings/user-detail.tsx +268 -0
  48. package/client/pages/settings/user-form.tsx +427 -0
  49. package/client/pages/settings/users.tsx +451 -0
  50. package/client/routing/app-router.tsx +4 -1
  51. package/client/routing/session-preparation.tsx +65 -0
  52. package/client/styles.css +4 -7
  53. package/package.json +27 -24
  54. package/tests/components/app-layout-routes.test.tsx +14 -7
  55. package/tests/components/data-table.test.tsx +127 -0
  56. package/tests/components/layout-i18n.test.tsx +8 -1
  57. package/tests/components/navigation-menu.test.tsx +24 -14
  58. package/tests/components/permission-editor.test.tsx +477 -0
  59. package/tests/components/permission-filter-binding.test.tsx +150 -0
  60. package/tests/components/permission-filter.test.tsx +202 -0
  61. package/tests/components/settings.test.tsx +294 -0
  62. package/tests/components/sidebar-permissions.test.tsx +12 -4
  63. package/tests/components/sidebar-preference.test.tsx +209 -14
  64. package/tests/components/user-permissions.test.tsx +142 -0
  65. package/tests/fixtures/theme-tokens.tsx +9 -1
  66. package/tests/logic/asset-url-build.test.ts +0 -1
  67. package/tests/logic/client-routes.test.ts +40 -0
  68. package/tests/logic/client-shell.test.tsx +10 -2
  69. package/tests/logic/config-example.test.ts +30 -0
  70. package/tests/logic/dev-client-watch.test.ts +8 -1
  71. package/tests/logic/page-permission-titles.test.ts +223 -0
  72. package/tests/logic/route-overlay.test.tsx +42 -0
  73. package/tests/logic/session-preparation.test.tsx +223 -0
  74. package/tests/logic/settings-permissions.test.ts +168 -0
  75. package/vite.config.ts +0 -22
  76. package/client/agent-annotations-host.ts +0 -25
@@ -0,0 +1,477 @@
1
+ import { useState } from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { PermissionEditor } from '../../client/components/permission-editor.js';
6
+ const sections = [
7
+ {
8
+ id: 'business',
9
+ title: 'Business actions',
10
+ groups: [
11
+ {
12
+ id: 'orders',
13
+ title: 'Orders',
14
+ rows: [
15
+ { id: 'read-orders', label: 'Read orders', enabled: true },
16
+ {
17
+ id: 'edit-orders',
18
+ label: 'Edit orders',
19
+ hint: 'Change the owner',
20
+ enabled: false,
21
+ },
22
+ ],
23
+ },
24
+ ],
25
+ },
26
+ {
27
+ id: 'settings',
28
+ title: 'System settings',
29
+ groups: [
30
+ {
31
+ id: 'users',
32
+ title: 'Users',
33
+ rows: [{ id: 'manage-users', label: 'Manage users', enabled: false }],
34
+ },
35
+ ],
36
+ },
37
+ ];
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();
42
+ render(
43
+ <PermissionEditor
44
+ sections={sections}
45
+ onEnabledChange={enabled}
46
+ onLevelChange={vi.fn()}
47
+ />,
48
+ );
49
+ expect(
50
+ screen.queryByRole('checkbox', { name: 'Manage users' }),
51
+ ).not.toBeInTheDocument();
52
+ await user.click(screen.getByRole('button', { name: /System settings/ }));
53
+ expect(
54
+ screen.getByRole('checkbox', { name: 'Manage users' }),
55
+ ).not.toBeChecked();
56
+ await user.type(screen.getByRole('textbox'), 'owner');
57
+ 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();
67
+ });
68
+ it('keeps counts stable, distinguishes no results, and permits searching a readonly workspace', async () => {
69
+ const user = userEvent.setup();
70
+ const enabled = vi.fn();
71
+ render(
72
+ <PermissionEditor
73
+ sections={sections}
74
+ readOnly
75
+ onEnabledChange={enabled}
76
+ onLevelChange={vi.fn()}
77
+ />,
78
+ );
79
+ await user.type(screen.getByRole('textbox'), 'not found');
80
+ expect(screen.getByRole('status')).toHaveTextContent(
81
+ 'No matching permissions',
82
+ );
83
+ expect(
84
+ screen.getByRole('button', { name: /Business actions/ }),
85
+ ).toHaveTextContent('1/2');
86
+ await user.clear(screen.getByRole('textbox'));
87
+ expect(
88
+ screen.getByRole('checkbox', { name: 'Read orders' }),
89
+ ).toHaveAttribute('aria-disabled', 'true');
90
+ await user.click(screen.getByRole('checkbox', { name: 'Read orders' }));
91
+ expect(enabled).not.toHaveBeenCalled();
92
+ });
93
+ });
94
+
95
+ describe('compact resource actions', () => {
96
+ it('batches only visible editable changes and shows a mixed group checkbox', async () => {
97
+ const user = userEvent.setup();
98
+ const batch = vi.fn();
99
+ render(
100
+ <PermissionEditor
101
+ sections={[
102
+ {
103
+ ...sections[0]!,
104
+ groups: [
105
+ {
106
+ id: 'orders',
107
+ title: 'Orders',
108
+ rows: [
109
+ ...sections[0]!.groups[0]!.rows,
110
+ {
111
+ id: 'locked',
112
+ label: 'Protected',
113
+ enabled: false,
114
+ toggleable: false,
115
+ },
116
+ ],
117
+ },
118
+ ],
119
+ },
120
+ ]}
121
+ onEnabledChange={vi.fn()}
122
+ onEnabledBatchChange={batch}
123
+ onLevelChange={vi.fn()}
124
+ />,
125
+ );
126
+ const group = screen.getByRole('checkbox', {
127
+ name: 'Select visible permissions in Orders',
128
+ });
129
+ expect(group).toHaveAttribute('aria-checked', 'mixed');
130
+ await user.click(group);
131
+ expect(batch).toHaveBeenLastCalledWith([
132
+ { id: 'edit-orders', enabled: true },
133
+ ]);
134
+ await user.type(screen.getByRole('textbox'), 'owner');
135
+ await user.click(
136
+ screen.getByRole('checkbox', {
137
+ name: 'Select visible permissions in Orders',
138
+ }),
139
+ );
140
+ expect(batch).toHaveBeenLastCalledWith([
141
+ { id: 'edit-orders', enabled: true },
142
+ ]);
143
+ expect(
144
+ screen.queryByRole('checkbox', { name: 'Protected' }),
145
+ ).not.toBeInTheDocument();
146
+ });
147
+ it('restores stable focus when enabled filtering removes the action or group', async () => {
148
+ const user = userEvent.setup();
149
+ function Draft() {
150
+ const [enabled, setEnabled] = useState(true);
151
+ return (
152
+ <PermissionEditor
153
+ sections={[
154
+ {
155
+ id: 's',
156
+ title: 'Section',
157
+ groups: [
158
+ {
159
+ id: 'g',
160
+ title: 'Group',
161
+ rows: [
162
+ { id: 'a', label: 'Action', enabled },
163
+ {
164
+ id: 'hidden',
165
+ label: 'Hidden',
166
+ enabled: false,
167
+ toggleable: false,
168
+ },
169
+ ],
170
+ },
171
+ ],
172
+ },
173
+ ]}
174
+ onEnabledChange={(_, on) => setEnabled(on)}
175
+ onEnabledBatchChange={(changes) => setEnabled(changes[0]!.enabled)}
176
+ onLevelChange={vi.fn()}
177
+ />
178
+ );
179
+ }
180
+ const view = render(<Draft />);
181
+ await user.click(screen.getByRole('switch', { name: 'Enabled only' }));
182
+ await user.click(screen.getByRole('checkbox', { name: 'Action' }));
183
+ expect(screen.getByRole('textbox')).toHaveFocus();
184
+ expect(screen.getByRole('status')).toHaveTextContent(
185
+ 'No matching permissions',
186
+ );
187
+ view.unmount();
188
+ render(<Draft />);
189
+ await user.click(screen.getByRole('switch', { name: 'Enabled only' }));
190
+ screen
191
+ .getByRole('checkbox', { name: 'Select visible permissions in Group' })
192
+ .focus();
193
+ await user.keyboard('[Space]');
194
+ expect(screen.getByRole('textbox')).toHaveFocus();
195
+ });
196
+ it('keeps scopes attached to actions, readonly scopes disabled and control ids instance-local', async () => {
197
+ const scoped = [
198
+ {
199
+ id: 's',
200
+ title: 'Section',
201
+ groups: [
202
+ {
203
+ id: 'g',
204
+ title: 'Orders',
205
+ rows: [
206
+ {
207
+ id: 'edit',
208
+ label: 'Edit',
209
+ enabled: true,
210
+ scopes: [
211
+ {
212
+ id: 'edit-records',
213
+ label: 'Records',
214
+ level: 'mine',
215
+ levels: [
216
+ { value: 'mine', label: 'Mine' },
217
+ { value: 'all', label: 'All' },
218
+ ],
219
+ },
220
+ ],
221
+ },
222
+ ],
223
+ },
224
+ ],
225
+ },
226
+ ];
227
+ const change = vi.fn();
228
+ const view = render(
229
+ <PermissionEditor
230
+ sections={scoped}
231
+ onEnabledChange={vi.fn()}
232
+ onLevelChange={change}
233
+ />,
234
+ );
235
+ expect(
236
+ view.container.querySelectorAll('[data-slot=permission-row]'),
237
+ ).toHaveLength(1);
238
+ await userEvent.click(
239
+ screen.getByRole('combobox', { name: 'How far Edit · Records reaches' }),
240
+ );
241
+ await userEvent.click(await screen.findByRole('option', { name: 'All' }));
242
+ expect(change).toHaveBeenCalledWith('edit-records', 'all');
243
+ view.unmount();
244
+ render(
245
+ <>
246
+ <PermissionEditor
247
+ sections={scoped}
248
+ readOnly
249
+ onEnabledChange={vi.fn()}
250
+ onLevelChange={vi.fn()}
251
+ />
252
+ <PermissionEditor
253
+ sections={scoped}
254
+ readOnly
255
+ onEnabledChange={vi.fn()}
256
+ onLevelChange={vi.fn()}
257
+ />
258
+ </>,
259
+ );
260
+ const boxes = screen.getAllByRole('checkbox', { name: 'Edit' });
261
+ expect(boxes[0]!.id).not.toBe(boxes[1]!.id);
262
+ expect(screen.getAllByRole('combobox')[0]).toBeDisabled();
263
+ expect(
264
+ screen.queryByRole('checkbox', { name: /Select visible/ }),
265
+ ).not.toBeInTheDocument();
266
+ });
267
+ it('does not submit an enclosing role form while searching or switching categories', async () => {
268
+ const submit = vi.fn((event) => event.preventDefault());
269
+ render(
270
+ <form onSubmit={submit}>
271
+ <PermissionEditor
272
+ sections={sections}
273
+ onEnabledChange={vi.fn()}
274
+ onLevelChange={vi.fn()}
275
+ />
276
+ </form>,
277
+ );
278
+ await userEvent.click(
279
+ screen.getByRole('button', { name: /System settings/ }),
280
+ );
281
+ await userEvent.type(screen.getByRole('textbox'), 'no match');
282
+ await userEvent.click(
283
+ screen.getByRole('button', { name: 'Clear filters' }),
284
+ );
285
+ expect(submit).not.toHaveBeenCalled();
286
+ });
287
+ });
288
+
289
+ it('shows a single page access checkbox without a duplicate access action or resource counter', async () => {
290
+ const onEnabledChange = vi.fn();
291
+ const onEnabledBatchChange = vi.fn();
292
+ render(
293
+ <PermissionEditor
294
+ sections={[
295
+ {
296
+ id: 'pages',
297
+ title: 'Page access',
298
+ groups: [
299
+ {
300
+ id: 'expenses',
301
+ title: 'Expense registration',
302
+ accessOnly: true,
303
+ rows: [
304
+ { id: 'page-expenses-access', label: 'Access', enabled: true },
305
+ ],
306
+ },
307
+ ],
308
+ },
309
+ ]}
310
+ onEnabledChange={onEnabledChange}
311
+ onEnabledBatchChange={onEnabledBatchChange}
312
+ onLevelChange={vi.fn()}
313
+ />,
314
+ );
315
+ expect(screen.getAllByRole('checkbox')).toHaveLength(1);
316
+ expect(screen.queryByText('Access')).not.toBeInTheDocument();
317
+ expect(screen.queryByText('1 of 1 enabled')).not.toBeInTheDocument();
318
+ await userEvent.click(
319
+ screen.getByRole('checkbox', { name: 'Expense registration' }),
320
+ );
321
+ expect(onEnabledChange).toHaveBeenCalledExactlyOnceWith(
322
+ 'page-expenses-access',
323
+ false,
324
+ );
325
+ expect(onEnabledBatchChange).not.toHaveBeenCalled();
326
+ });
327
+
328
+ it('keeps multiple inline scopes independently named and editable', async () => {
329
+ const change = vi.fn();
330
+ render(
331
+ <PermissionEditor
332
+ sections={[
333
+ {
334
+ id: 's',
335
+ title: 'Business',
336
+ groups: [
337
+ {
338
+ id: 'g',
339
+ title: 'Expenses',
340
+ rows: [
341
+ {
342
+ id: 'view',
343
+ label: 'View',
344
+ enabled: true,
345
+ scopes: ['Records', 'Attachments'].map((label) => ({
346
+ id: label.toLowerCase(),
347
+ label,
348
+ level: 'own',
349
+ levels: [
350
+ { value: 'own', label: 'Own' },
351
+ { value: 'all', label: 'All' },
352
+ ],
353
+ })),
354
+ },
355
+ ],
356
+ },
357
+ ],
358
+ },
359
+ ]}
360
+ onEnabledChange={vi.fn()}
361
+ onLevelChange={change}
362
+ />,
363
+ );
364
+ expect(screen.getByText('Records')).toBeVisible();
365
+ expect(screen.getByText('Attachments')).toBeVisible();
366
+ await userEvent.click(
367
+ screen.getByRole('combobox', {
368
+ name: 'How far View · Attachments reaches',
369
+ }),
370
+ );
371
+ await userEvent.click(await screen.findByRole('option', { name: 'All' }));
372
+ expect(change).toHaveBeenCalledExactlyOnceWith('attachments', 'all');
373
+ });
374
+
375
+ it('keeps the category heading consistent for a single resource and labels bulk selection explicitly', async () => {
376
+ const batch = vi.fn();
377
+ render(
378
+ <PermissionEditor
379
+ sections={[
380
+ {
381
+ id: 'users',
382
+ title: 'Users',
383
+ groups: [
384
+ {
385
+ id: 'users',
386
+ title: 'Users',
387
+ rows: [
388
+ {
389
+ id: 'read',
390
+ label: 'View users',
391
+ actionKey: 'read',
392
+ enabled: true,
393
+ },
394
+ {
395
+ id: 'edit',
396
+ label: 'Edit users',
397
+ actionKey: 'update',
398
+ enabled: false,
399
+ },
400
+ ],
401
+ },
402
+ ],
403
+ },
404
+ ]}
405
+ onEnabledChange={vi.fn()}
406
+ onEnabledBatchChange={batch}
407
+ onLevelChange={vi.fn()}
408
+ />,
409
+ );
410
+ expect(
411
+ screen.getByRole('heading', { level: 2, name: 'Users' }),
412
+ ).toBeInTheDocument();
413
+ expect(screen.getAllByText('1/2')).toHaveLength(1);
414
+ expect(
415
+ screen.getByRole('checkbox', {
416
+ name: 'Select visible permissions in Users',
417
+ }),
418
+ ).toHaveAttribute('aria-checked', 'mixed');
419
+ await userEvent.click(screen.getByText('All', { selector: 'span' }));
420
+ expect(batch).toHaveBeenCalledExactlyOnceWith([
421
+ { id: 'edit', enabled: true },
422
+ ]);
423
+ });
424
+
425
+ it('aligns semantic actions across resources without dropping special or filtered actions', async () => {
426
+ const groups = [
427
+ {
428
+ id: 'one',
429
+ title: 'One',
430
+ rows: [
431
+ {
432
+ id: 'edit-one',
433
+ actionKey: 'update',
434
+ label: 'Edit one',
435
+ enabled: true,
436
+ },
437
+ { id: 'read-one', actionKey: 'read', label: 'View one', enabled: true },
438
+ ],
439
+ },
440
+ {
441
+ id: 'two',
442
+ title: 'Two',
443
+ rows: [
444
+ { id: 'read-two', actionKey: 'read', label: 'View two', enabled: true },
445
+ {
446
+ id: 'special',
447
+ actionKey: 'export',
448
+ label: 'Export two',
449
+ enabled: false,
450
+ },
451
+ ],
452
+ },
453
+ ];
454
+ const view = render(
455
+ <PermissionEditor
456
+ sections={[{ id: 'business', title: 'Business', groups }]}
457
+ onEnabledChange={vi.fn()}
458
+ onLevelChange={vi.fn()}
459
+ />,
460
+ );
461
+ const column = (name: string) =>
462
+ screen
463
+ .getByRole('checkbox', { name })
464
+ .closest('[data-slot=permission-row]')!
465
+ .parentElement!.style.getPropertyValue('--permission-column');
466
+ expect(column('View one')).toBe(column('View two'));
467
+ expect(column('Edit one')).not.toBe(column('Export two'));
468
+ expect(screen.getAllByRole('checkbox')).toHaveLength(4);
469
+ await userEvent.type(screen.getByRole('textbox'), 'Export');
470
+ expect(
471
+ screen.getByRole('checkbox', { name: 'Export two' }),
472
+ ).toBeInTheDocument();
473
+ expect(column('Export two')).toBe('3');
474
+ expect(
475
+ view.container.querySelectorAll('[data-slot=permission-row]'),
476
+ ).toHaveLength(1);
477
+ });
@@ -0,0 +1,150 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { useState } from 'react';
4
+ import { expect, it, vi } from 'vitest';
5
+ import type {
6
+ AuthorizationOptionsResponse,
7
+ PermissionGrant,
8
+ } from '@nocobase/app-plugin-authorization/client';
9
+ import { PermissionEditor } from '../../client/components/permission-editor.js';
10
+ import { configurePermissionScopes } from '../../client/pages/settings/permission-filters.js';
11
+ import {
12
+ permissionSections,
13
+ setScope,
14
+ } from '../../client/pages/settings/permissions-model.js';
15
+ import enUS from '../../client/locales/en-US.js';
16
+ const catalog = {
17
+ sections: [
18
+ {
19
+ name: 'business',
20
+ title: 'Business',
21
+ subsections: [
22
+ {
23
+ name: 'expenses',
24
+ title: 'Expenses',
25
+ resources: [
26
+ {
27
+ type: 'composite',
28
+ id: 'expenses.records',
29
+ title: 'Expenses',
30
+ actions: [{ name: 'view', title: 'View' }],
31
+ dataScopes: {
32
+ view: [
33
+ {
34
+ key: 'records',
35
+ title: 'Records',
36
+ collection: 'expenses',
37
+ fields: ['department'],
38
+ recordAccess: ['recordsIOwn', 'customFilter'],
39
+ },
40
+ ],
41
+ },
42
+ },
43
+ ],
44
+ },
45
+ ],
46
+ },
47
+ ],
48
+ recordAccess: [
49
+ { key: 'recordsIOwn', title: 'Own', collections: ['expenses'] },
50
+ { key: 'customFilter', title: 'Custom', collections: ['expenses'] },
51
+ ],
52
+ } as unknown as AuthorizationOptionsResponse;
53
+ function text(key: string, fallback?: string): string {
54
+ let value: unknown = enUS;
55
+ for (const part of key.split('.'))
56
+ value =
57
+ value && typeof value === 'object' ? Reflect.get(value, part) : undefined;
58
+ return typeof value === 'string' ? value : (fallback ?? key);
59
+ }
60
+ it('collects native custom filter parameters before changing the draft, then saves and reopens them', async () => {
61
+ const user = userEvent.setup(),
62
+ save = vi.fn();
63
+ function Role() {
64
+ const [grants, setGrants] = useState<readonly PermissionGrant[]>([
65
+ {
66
+ resource: { type: 'composite', id: 'expenses.records' },
67
+ actions: [
68
+ {
69
+ action: 'view',
70
+ policy: {
71
+ type: 'composite',
72
+ scopes: { records: 'recordsIOwn', unrelated: { keep: true } },
73
+ },
74
+ },
75
+ ],
76
+ },
77
+ ]);
78
+ const [editing, setEditing] = useState<string>();
79
+ const sections = configurePermissionScopes(
80
+ permissionSections(catalog, grants, (value) =>
81
+ typeof value === 'string' ? value : text(value.key),
82
+ ),
83
+ catalog,
84
+ grants,
85
+ text,
86
+ editing,
87
+ setEditing,
88
+ (id, value) => setGrants((current) => setScope(current, id, value)),
89
+ false,
90
+ );
91
+ return (
92
+ <form
93
+ onSubmit={(event) => {
94
+ event.preventDefault();
95
+ save(grants);
96
+ }}
97
+ >
98
+ <PermissionEditor
99
+ sections={sections}
100
+ onEnabledChange={vi.fn()}
101
+ onLevelChange={(id, level) => {
102
+ if (level === 'customFilter') setEditing(id);
103
+ else setGrants((current) => setScope(current, id, level));
104
+ }}
105
+ />
106
+ <button type='submit'>Save role</button>
107
+ </form>
108
+ );
109
+ }
110
+ render(<Role />);
111
+ const scope = screen.getByRole('combobox', {
112
+ name: 'How far View · Records reaches',
113
+ });
114
+ expect(scope).toHaveTextContent('Own');
115
+ await user.click(scope);
116
+ await user.click(await screen.findByRole('option', { name: 'Custom' }));
117
+ expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
118
+ expect(save).not.toHaveBeenCalled();
119
+ await user.type(screen.getByRole('textbox', { name: 'Value 1' }), 'Finance');
120
+ await user.keyboard('{Enter}');
121
+ expect(save).not.toHaveBeenCalled();
122
+ await user.click(screen.getByRole('button', { name: 'Apply' }));
123
+ expect(scope).toHaveTextContent('Custom');
124
+ await user.click(screen.getByRole('button', { name: 'Save role' }));
125
+ expect(save.mock.calls[0]?.[0][0].actions[0].policy.scopes).toEqual({
126
+ records: {
127
+ type: 'recordAccess',
128
+ key: 'customFilter',
129
+ params: {
130
+ filter: {
131
+ kind: 'group',
132
+ logic: 'and',
133
+ items: [
134
+ {
135
+ kind: 'condition',
136
+ path: ['department'],
137
+ operator: '$eq',
138
+ value: 'Finance',
139
+ },
140
+ ],
141
+ },
142
+ },
143
+ },
144
+ unrelated: { keep: true },
145
+ });
146
+ await user.click(screen.getByRole('button', { name: 'Conditions' }));
147
+ expect(screen.getByRole('textbox', { name: 'Value 1' })).toHaveValue(
148
+ 'Finance',
149
+ );
150
+ });