@nocobase/app-template-default 1.0.0-beta.76 → 1.0.0-beta.78
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.
- package/client/components/default-access-editor.tsx +79 -52
- package/client/components/long-text.tsx +200 -0
- package/client/components/permission-editor.tsx +534 -341
- package/client/locales/system/settings-en-US.ts +14 -3
- package/client/locales/system/settings-zh-CN.ts +13 -3
- package/client/pages/home/use-copy.ts +6 -3
- package/client/pages/settings/default-access-form.tsx +10 -1
- package/client/pages/settings/default-access-model.ts +6 -2
- package/client/pages/settings/page-permissions.ts +166 -1
- package/client/pages/settings/permission-filters.tsx +9 -2
- package/client/pages/settings/permission-workspace.ts +330 -0
- package/client/pages/settings/permissions-model.ts +62 -53
- package/client/pages/settings/role-form.tsx +30 -94
- package/package.json +16 -16
- package/server/config/app.ts +1 -1
- package/server/config/session.ts +7 -5
- package/tests/components/default-access-editor.test.tsx +130 -0
- package/tests/components/permission-editor.test.tsx +613 -572
- package/tests/components/permission-filter-binding.test.tsx +20 -0
- package/tests/components/settings.test.tsx +33 -8
- package/tests/components/user-drawer.test.tsx +14 -3
- package/tests/components/user-permissions.test.tsx +47 -13
- package/tests/logic/config.test.ts +3 -1
- package/tests/logic/page-permission-navigation.test.ts +394 -0
- package/tests/logic/permission-workspace.test.ts +570 -0
- package/tests/logic/session-isolation.test.ts +250 -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 {
|
|
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: '
|
|
18
|
-
label: '
|
|
19
|
-
|
|
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: '
|
|
28
|
-
title: '
|
|
40
|
+
id: 'configuration',
|
|
41
|
+
title: 'Application configuration',
|
|
29
42
|
groups: [
|
|
30
43
|
{
|
|
31
|
-
id: '
|
|
32
|
-
title: '
|
|
33
|
-
rows: [
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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={
|
|
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: '
|
|
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('
|
|
55
|
-
).
|
|
56
|
-
await user.type(screen.getByRole('textbox'), 'owner');
|
|
77
|
+
screen.getByRole('navigation', { name: 'Permission categories' }),
|
|
78
|
+
).toBeVisible();
|
|
57
79
|
expect(
|
|
58
|
-
screen.
|
|
59
|
-
).
|
|
60
|
-
expect(
|
|
61
|
-
|
|
62
|
-
expect(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
105
|
+
|
|
106
|
+
it('filters globally by descriptions and parent names without changing the draft', async () => {
|
|
69
107
|
const user = userEvent.setup();
|
|
70
|
-
const
|
|
108
|
+
const change = vi.fn();
|
|
71
109
|
render(
|
|
72
110
|
<PermissionEditor
|
|
73
111
|
sections={sections}
|
|
74
|
-
|
|
75
|
-
onEnabledChange={enabled}
|
|
112
|
+
onEnabledChange={change}
|
|
76
113
|
onLevelChange={vi.fn()}
|
|
77
114
|
/>,
|
|
78
115
|
);
|
|
79
|
-
await user.type(screen.getByRole('textbox'), '
|
|
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('
|
|
85
|
-
).
|
|
86
|
-
expect(
|
|
87
|
-
|
|
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: '
|
|
90
|
-
).
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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={(_,
|
|
125
|
-
onEnabledBatchChange={
|
|
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('
|
|
132
|
-
await user.click(screen.getByRole('checkbox', { name: '
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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: '
|
|
146
|
-
title: '
|
|
147
|
-
|
|
271
|
+
id: 's',
|
|
272
|
+
title: 'Section',
|
|
273
|
+
groups: [
|
|
148
274
|
{
|
|
149
|
-
id: '
|
|
150
|
-
|
|
151
|
-
enabled: true,
|
|
152
|
-
scopes: [
|
|
275
|
+
id: 'g',
|
|
276
|
+
rows: [
|
|
153
277
|
{
|
|
154
|
-
id: '
|
|
155
|
-
label: '
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
179
|
-
).
|
|
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.
|
|
207
|
-
).
|
|
296
|
+
screen.getByText('This permission is managed by the application.'),
|
|
297
|
+
).toBeVisible();
|
|
208
298
|
});
|
|
209
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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('
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
364
|
+
describe('action-owned data ranges', () => {
|
|
365
|
+
const scoped = [
|
|
366
|
+
{
|
|
367
|
+
id: 's',
|
|
368
|
+
title: 'Business',
|
|
369
|
+
groups: [
|
|
275
370
|
{
|
|
276
|
-
id: '
|
|
277
|
-
title: '
|
|
278
|
-
|
|
371
|
+
id: 'g',
|
|
372
|
+
title: 'Borrowing',
|
|
373
|
+
rows: [
|
|
279
374
|
{
|
|
280
|
-
id: '
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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('
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
{
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
522
|
+
const treeSection: PermissionEditorSection = {
|
|
523
|
+
id: 'pages',
|
|
524
|
+
title: 'Page access',
|
|
525
|
+
groups: [
|
|
377
526
|
{
|
|
378
|
-
id: '
|
|
379
|
-
title: 'One',
|
|
527
|
+
id: 'page-rows',
|
|
380
528
|
rows: [
|
|
381
|
-
{
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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: '
|
|
392
|
-
|
|
393
|
-
|
|
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: '
|
|
436
|
-
|
|
437
|
-
|
|
542
|
+
id: 'catalog-node',
|
|
543
|
+
label: 'Catalog',
|
|
544
|
+
rowId: 'catalog',
|
|
545
|
+
children: [
|
|
546
|
+
{ id: 'books-node', label: 'Books', rowId: 'books' },
|
|
438
547
|
{
|
|
439
|
-
id: '
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
{
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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('
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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('
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
{
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
rows: [
|
|
672
|
+
...treeSection,
|
|
673
|
+
groups: [
|
|
655
674
|
{
|
|
656
|
-
id: '
|
|
657
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
});
|