@nocobase/app-template-default 1.0.0-beta.84 → 1.0.0-beta.86

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 (42) hide show
  1. package/AGENTS.md +1 -1
  2. package/client/components/data-table/data-table.tsx +86 -32
  3. package/client/components/list-feedback.tsx +174 -0
  4. package/client/components/permission-editor.tsx +40 -33
  5. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  6. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  7. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  8. package/client/hooks/use-list-data.ts +1 -2
  9. package/client/locales/system/en-US.ts +14 -1
  10. package/client/locales/system/settings-en-US.ts +0 -10
  11. package/client/locales/system/settings-zh-CN.ts +0 -8
  12. package/client/locales/system/zh-CN.ts +14 -1
  13. package/client/pages/settings/default-access.tsx +50 -72
  14. package/client/pages/settings/permissions-model.ts +0 -1
  15. package/client/pages/settings/roles.tsx +63 -97
  16. package/client/pages/settings/users.tsx +72 -70
  17. package/client/runtime.ts +0 -4
  18. package/config.example.yml +24 -0
  19. package/package.json +33 -31
  20. package/server/app.ts +0 -2
  21. package/server/config/auth.ts +2 -0
  22. package/server/config/concurrency.ts +3 -0
  23. package/server/config/index.ts +3 -0
  24. package/server/config/jobs.ts +1 -13
  25. package/server/config/notification.ts +1 -1
  26. package/server/config/queue.ts +1 -7
  27. package/server/plugins.ts +2 -0
  28. package/tests/client/data-table.test.tsx +9 -4
  29. package/tests/client/default-access.test.tsx +43 -5
  30. package/tests/client/list-feedback.test.tsx +301 -0
  31. package/tests/client/manual-list-retry.test.tsx +190 -0
  32. package/tests/client/permission-workspace.test.ts +13 -0
  33. package/tests/client/settings-continuity.test.tsx +80 -1
  34. package/tests/client/settings-feedback.test.tsx +7 -15
  35. package/tests/fixtures/permission-alignment.html +16 -0
  36. package/tests/fixtures/permission-alignment.tsx +124 -0
  37. package/tests/playwright/permission-alignment.test.ts +109 -0
  38. package/tests/server/auth-email-config.test.ts +79 -0
  39. package/tests/server/concurrency.test.ts +125 -0
  40. package/tests/server/config.test.ts +34 -14
  41. package/client/route-overrides.ts +0 -9
  42. package/client/source-extensions.ts +0 -19
@@ -0,0 +1,301 @@
1
+ import { useState } from 'react';
2
+ import { renderWithApp } from '@nocobase/app-testing/client';
3
+ import type { ColumnDef } from '@tanstack/react-table';
4
+ import { screen, within } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import {
8
+ ListFeedback,
9
+ ListFeedbackActions,
10
+ } from '../../client/components/list-feedback.js';
11
+ import { DataTable } from '../../client/components/data-table/index.js';
12
+ import enUS from '../../client/locales/en-US.js';
13
+ import zhCN from '../../client/locales/zh-CN.js';
14
+
15
+ const namespace = '@nocobase/list-feedback-test';
16
+ const namespaces = {
17
+ [namespace]: {
18
+ 'en-US': async () => ({ default: enUS }),
19
+ 'zh-CN': async () => ({ default: zhCN }),
20
+ },
21
+ };
22
+ const options = { namespace, namespaces };
23
+
24
+ function RetainedEditor() {
25
+ const [value, setValue] = useState(0);
26
+ return <button onClick={() => setValue(value + 1)}>Edited {value}</button>;
27
+ }
28
+
29
+ describe('shared table and card feedback', () => {
30
+ it('keeps mounted business content and local edits through a refresh failure and recovery', async () => {
31
+ const view = await renderWithApp(
32
+ <ListFeedback state='ready'>
33
+ <RetainedEditor />
34
+ </ListFeedback>,
35
+ options,
36
+ );
37
+ await userEvent
38
+ .setup()
39
+ .click(screen.getByRole('button', { name: 'Edited 0' }));
40
+ view.rerender(
41
+ <ListFeedback state='refresh-failed'>
42
+ <RetainedEditor />
43
+ </ListFeedback>,
44
+ );
45
+ expect(screen.getByRole('button', { name: 'Edited 1' })).toBeVisible();
46
+ expect(screen.getByRole('alert').textContent).toBe(
47
+ enUS.listFeedback.refreshFailed,
48
+ );
49
+ view.rerender(
50
+ <ListFeedback state='ready'>
51
+ <RetainedEditor />
52
+ </ListFeedback>,
53
+ );
54
+ expect(screen.getByRole('button', { name: 'Edited 1' })).toBeVisible();
55
+ });
56
+
57
+ it('clears local column filters through the table toolbar and resets its input', async () => {
58
+ const user = userEvent.setup();
59
+ await renderWithApp(
60
+ <DataTable
61
+ data={[{ id: 'Camera' }]}
62
+ columns={[{ accessorKey: 'id', header: 'ID' }]}
63
+ listState='ready'
64
+ rowCount={1}
65
+ toolbar={(table) => (
66
+ <input
67
+ aria-label='Camera filter'
68
+ value={String(table.getColumn('id')?.getFilterValue() ?? '')}
69
+ onChange={(event) =>
70
+ table.getColumn('id')?.setFilterValue(event.target.value)
71
+ }
72
+ />
73
+ )}
74
+ />,
75
+ options,
76
+ );
77
+ await user.type(screen.getByLabelText('Camera filter'), 'missing');
78
+ const status = screen.getByRole('status');
79
+ expect(status.textContent).toBe(enUS.listFeedback.noResults);
80
+ expect(status.closest('td')).not.toBeNull();
81
+ expect(within(status).queryByRole('button')).not.toBeInTheDocument();
82
+ expect(screen.getByRole('table')).toBeVisible();
83
+ expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible();
84
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }));
85
+ expect(screen.getByLabelText('Camera filter')).toHaveValue('');
86
+ expect(screen.getByRole('cell', { name: 'Camera' })).toBeVisible();
87
+ });
88
+
89
+ it('keeps loading and denied states distinct and hides cached rows on denial', async () => {
90
+ const view = await renderWithApp(
91
+ <DataTable
92
+ data={[]}
93
+ columns={[{ accessorKey: 'id', header: 'ID' }]}
94
+ loading
95
+ emptyMessage='Business empty'
96
+ emptyAction={<button>Create record</button>}
97
+ />,
98
+ options,
99
+ );
100
+ expect(screen.getByRole('status').textContent).toBe(
101
+ enUS.listFeedback.loading,
102
+ );
103
+ expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible();
104
+ expect(screen.queryByText('Business empty')).not.toBeInTheDocument();
105
+ expect(screen.queryByText('Create record')).not.toBeInTheDocument();
106
+ view.rerender(
107
+ <DataTable
108
+ data={[{ id: 'Private row' }]}
109
+ columns={[{ accessorKey: 'id', header: 'ID' }]}
110
+ listState='forbidden'
111
+ emptyMessage='Business empty'
112
+ />,
113
+ );
114
+ expect(screen.getByRole('alert').textContent).toBe(
115
+ enUS.listFeedback.forbidden,
116
+ );
117
+ expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible();
118
+ expect(screen.queryByText('Private row')).not.toBeInTheDocument();
119
+ });
120
+
121
+ it('keeps table structure and places each non-ready state in its body', async () => {
122
+ const user = userEvent.setup();
123
+ const columns: ColumnDef<{ id: string }>[] = [
124
+ { accessorKey: 'id', header: 'ID' },
125
+ ];
126
+ const toolbar = () => <button type='button'>Search tools</button>;
127
+ const view = await renderWithApp(
128
+ <DataTable
129
+ columns={columns}
130
+ data={[]}
131
+ listState='loading'
132
+ toolbar={toolbar}
133
+ onClearFilters={() => undefined}
134
+ onRetry={() => undefined}
135
+ />,
136
+ options,
137
+ );
138
+ const states = [
139
+ ['loading', 'status'],
140
+ ['empty', 'status'],
141
+ ['no-results', 'status'],
142
+ ['forbidden', 'alert'],
143
+ ['unauthenticated', 'alert'],
144
+ ['failed', 'alert'],
145
+ ] as const;
146
+ for (const [state, role] of states) {
147
+ view.rerender(
148
+ <DataTable
149
+ columns={columns}
150
+ data={[]}
151
+ listState={state}
152
+ toolbar={toolbar}
153
+ onClearFilters={() => undefined}
154
+ onRetry={() => undefined}
155
+ />,
156
+ );
157
+ expect(screen.getByRole('table')).toBeVisible();
158
+ expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible();
159
+ expect(
160
+ screen.getByRole('button', { name: 'Search tools' }),
161
+ ).toBeVisible();
162
+ expect(screen.getByRole(role).closest('td')).not.toBeNull();
163
+ }
164
+
165
+ view.rerender(
166
+ <DataTable
167
+ columns={columns}
168
+ data={[{ id: 'Previous result' }]}
169
+ listState='refresh-failed'
170
+ toolbar={toolbar}
171
+ onRetry={() => undefined}
172
+ />,
173
+ );
174
+ expect(screen.getByRole('alert').closest('td')).not.toBeNull();
175
+ expect(screen.getByText('Previous result')).toBeVisible();
176
+ expect(screen.getByRole('button', { name: 'Search tools' })).toBeVisible();
177
+ await user.click(screen.getByRole('button', { name: 'Retry' }));
178
+ });
179
+
180
+ it('renders each built-in state as one icon and one short line without inline actions', async () => {
181
+ const view = await renderWithApp(<ListFeedback state='loading' />, options);
182
+ const states = [
183
+ ['loading', 'status'],
184
+ ['empty', 'status'],
185
+ ['no-results', 'status'],
186
+ ['forbidden', 'alert'],
187
+ ['unauthenticated', 'alert'],
188
+ ['failed', 'alert'],
189
+ ] as const;
190
+ for (const [state, role] of states) {
191
+ view.rerender(<ListFeedback state={state} />);
192
+ const status = screen.getByRole(role);
193
+ expect(status.textContent).toBe(
194
+ enUS.listFeedback[state === 'no-results' ? 'noResults' : state],
195
+ );
196
+ expect(status.querySelector('svg')).toHaveAttribute(
197
+ 'aria-hidden',
198
+ 'true',
199
+ );
200
+ expect(within(status).queryByRole('button')).not.toBeInTheDocument();
201
+ }
202
+ });
203
+
204
+ it('preserves custom empty content and its intentional business action', async () => {
205
+ await renderWithApp(
206
+ <ListFeedback
207
+ state='empty'
208
+ empty='Business empty'
209
+ emptyAction={<button>Create record</button>}
210
+ />,
211
+ options,
212
+ );
213
+ expect(screen.getByText(enUS.listFeedback.empty)).toBeVisible();
214
+ expect(screen.getByText('Business empty')).toBeVisible();
215
+ expect(screen.getByRole('button', { name: 'Create record' })).toBeVisible();
216
+ });
217
+
218
+ it('clears filtered results from the host toolbar with keyboard access', async () => {
219
+ const clear = vi.fn();
220
+ await renderWithApp(
221
+ <>
222
+ <div>
223
+ <ListFeedbackActions state='no-results' onClear={clear} />
224
+ </div>
225
+ <ListFeedback state='no-results' />
226
+ </>,
227
+ { ...options, locale: 'zh-CN' },
228
+ );
229
+ const status = screen.getByRole('status');
230
+ expect(status.textContent).toBe(zhCN.listFeedback.noResults);
231
+ expect(within(status).queryByRole('button')).not.toBeInTheDocument();
232
+ const button = screen.getByRole('button', {
233
+ name: zhCN.listFeedback.clear,
234
+ });
235
+ button.focus();
236
+ await userEvent.setup().keyboard('{Enter}');
237
+ expect(clear).toHaveBeenCalledOnce();
238
+ });
239
+
240
+ it('keeps one stale-data line while showing old content and places retry outside it', async () => {
241
+ const retry = vi.fn();
242
+ await renderWithApp(
243
+ <>
244
+ <div>
245
+ <ListFeedbackActions state='refresh-failed' onRetry={retry} />
246
+ </div>
247
+ <ListFeedback state='refresh-failed'>
248
+ <p>Retained equipment record</p>
249
+ </ListFeedback>
250
+ </>,
251
+ options,
252
+ );
253
+ const alert = screen.getByRole('alert');
254
+ expect(alert.textContent).toBe(enUS.listFeedback.refreshFailed);
255
+ expect(within(alert).queryByRole('button')).not.toBeInTheDocument();
256
+ expect(screen.getByText('Retained equipment record')).toBeVisible();
257
+ await userEvent
258
+ .setup()
259
+ .click(screen.getByRole('button', { name: 'Retry' }));
260
+ expect(retry).toHaveBeenCalledOnce();
261
+ });
262
+
263
+ it('keeps retry busy feedback in the toolbar', async () => {
264
+ await renderWithApp(
265
+ <>
266
+ <div>
267
+ <ListFeedbackActions state='failed' onRetry={vi.fn()} busy />
268
+ </div>
269
+ <ListFeedback state='failed' />
270
+ </>,
271
+ options,
272
+ );
273
+ expect(screen.getByRole('alert').textContent).toBe(
274
+ enUS.listFeedback.failed,
275
+ );
276
+ expect(
277
+ screen.getByRole('button', { name: enUS.listFeedback.retrying }),
278
+ ).toBeDisabled();
279
+ });
280
+
281
+ it('uses the existing login route outside the session status', async () => {
282
+ await renderWithApp(
283
+ <>
284
+ <div>
285
+ <ListFeedbackActions state='unauthenticated' />
286
+ </div>
287
+ <ListFeedback state='unauthenticated'>
288
+ <p>Private record</p>
289
+ </ListFeedback>
290
+ </>,
291
+ options,
292
+ );
293
+ expect(screen.getByRole('alert').textContent).toBe(
294
+ enUS.listFeedback.unauthenticated,
295
+ );
296
+ expect(screen.queryByText('Private record')).not.toBeInTheDocument();
297
+ expect(
298
+ screen.getByRole('button', { name: enUS.listFeedback.signIn }),
299
+ ).toHaveAttribute('href', '/login');
300
+ });
301
+ });
@@ -0,0 +1,190 @@
1
+ import { useState } from 'react';
2
+ import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
3
+ import { resolveListState } from '@nocobase/app-client';
4
+ import { act, screen, waitFor } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { useSettingsData } from '../../client/pages/settings/hooks.js';
8
+
9
+ import {
10
+ ListFeedback,
11
+ ListFeedbackActions,
12
+ } from '../../client/components/list-feedback.js';
13
+ import enUS from '../../client/locales/en-US.js';
14
+
15
+ type Rows = { data: string[] };
16
+ const options = {
17
+ namespace: '@nocobase/manual-list-test',
18
+ namespaces: {
19
+ '@nocobase/manual-list-test': {
20
+ 'en-US': async () => ({ default: enUS }),
21
+ },
22
+ },
23
+ };
24
+ function deferred() {
25
+ let resolve!: (value: Rows | Response) => void;
26
+ const promise = new Promise<Rows | Response>((done) => {
27
+ resolve = done;
28
+ });
29
+ return { promise, resolve };
30
+ }
31
+ function failure(status = 500) {
32
+ return new Response(JSON.stringify({ error: { message: 'Unavailable' } }), {
33
+ status,
34
+ headers: { 'content-type': 'application/json' },
35
+ });
36
+ }
37
+ const adapters = {
38
+ useSettings: (query: string) =>
39
+ useSettingsData<Rows>(`manual-list?q=${query}&page=3&orderBy=title`),
40
+ };
41
+ function Harness({ kind }: { readonly kind: keyof typeof adapters }) {
42
+ const [query, setQuery] = useState('first');
43
+ const useData = adapters[kind];
44
+ const request = useData(query);
45
+ const state = resolveListState(request, request.data?.data.length ?? 0);
46
+ const useRetryAction = state === 'failed' || state === 'refresh-failed';
47
+ return (
48
+ <>
49
+ <div>
50
+ {useRetryAction ? (
51
+ <ListFeedbackActions
52
+ state={state}
53
+ onRetry={request.reload}
54
+ busy={request.loading}
55
+ />
56
+ ) : (
57
+ <button onClick={request.reload}>Refresh</button>
58
+ )}
59
+ <button onClick={() => setQuery('second')}>Change query</button>
60
+ <ListFeedbackActions state={state} />
61
+ </div>
62
+ <ListFeedback state={state}>
63
+ {request.data?.data.map((row) => (
64
+ <p key={row}>{row}</p>
65
+ ))}
66
+ </ListFeedback>
67
+ </>
68
+ );
69
+ }
70
+ describe.each(Object.keys(adapters) as (keyof typeof adapters)[])(
71
+ '%s manual list',
72
+ (kind) => {
73
+ it.each(['success', 'failure'] as const)(
74
+ 'retains the stale warning during retry until %s',
75
+ async (outcome) => {
76
+ let response: Rows | Response | Promise<Rows | Response> = {
77
+ data: ['Original row'],
78
+ };
79
+ const calls: string[] = [];
80
+ await renderWithApp(<Harness kind={kind} />, {
81
+ ...options,
82
+ fetch: answerApi(({ path, query }) => {
83
+ calls.push(JSON.stringify({ path, query }));
84
+ return response;
85
+ }),
86
+ });
87
+ await screen.findByText('Original row');
88
+ response = failure();
89
+ await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
90
+ await screen.findByText(enUS.listFeedback.refreshFailed);
91
+ const pending = deferred();
92
+ response = pending.promise;
93
+ await userEvent.click(
94
+ screen.getByRole('button', { name: 'Retry', exact: true }),
95
+ );
96
+ expect(screen.getByRole('alert')).toHaveTextContent(
97
+ enUS.listFeedback.refreshFailed,
98
+ );
99
+ expect(screen.getByText('Original row')).toBeVisible();
100
+ expect(
101
+ screen.getByRole('button', { name: 'Retrying…' }),
102
+ ).toBeDisabled();
103
+ expect(calls).toHaveLength(3);
104
+ expect(new Set(calls).size).toBe(1);
105
+ await act(async () =>
106
+ pending.resolve(
107
+ outcome === 'success' ? { data: ['Updated row'] } : failure(),
108
+ ),
109
+ );
110
+ if (outcome === 'success') {
111
+ await screen.findByText('Updated row');
112
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
113
+ } else {
114
+ await waitFor(() =>
115
+ expect(
116
+ screen.getByRole('button', { name: 'Retry', exact: true }),
117
+ ).toBeEnabled(),
118
+ );
119
+ expect(screen.getByRole('alert')).toHaveTextContent(
120
+ enUS.listFeedback.refreshFailed,
121
+ );
122
+ expect(screen.getByText('Original row')).toBeVisible();
123
+ }
124
+ },
125
+ );
126
+ it.each([401, 403])(
127
+ 'hides cached rows through a pending retry after %s',
128
+ async (status) => {
129
+ let response: Rows | Response | Promise<Rows | Response> = {
130
+ data: ['Private row'],
131
+ };
132
+ await renderWithApp(<Harness kind={kind} />, {
133
+ ...options,
134
+ fetch: answerApi(() => response),
135
+ });
136
+ await screen.findByText('Private row');
137
+ response = failure(status);
138
+ await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
139
+ await screen.findByText(
140
+ status === 401
141
+ ? enUS.listFeedback.unauthenticated
142
+ : enUS.listFeedback.forbidden,
143
+ );
144
+ expect(screen.queryByText('Private row')).not.toBeInTheDocument();
145
+ const pending = deferred();
146
+ response = pending.promise;
147
+ await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
148
+ expect(screen.getByRole('alert')).toHaveTextContent(
149
+ status === 401
150
+ ? enUS.listFeedback.unauthenticated
151
+ : enUS.listFeedback.forbidden,
152
+ );
153
+ expect(screen.queryByText('Private row')).not.toBeInTheDocument();
154
+ await act(async () => pending.resolve({ data: ['Authorized row'] }));
155
+ await screen.findByText('Authorized row');
156
+ },
157
+ );
158
+ it('withholds the previous query and ignores its late retry completion', async () => {
159
+ let response: Rows | Response | Promise<Rows | Response> = {
160
+ data: ['First row'],
161
+ };
162
+ await renderWithApp(<Harness kind={kind} />, {
163
+ ...options,
164
+ fetch: answerApi(() => response),
165
+ });
166
+ await screen.findByText('First row');
167
+ response = failure();
168
+ await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
169
+ await screen.findByText(enUS.listFeedback.refreshFailed);
170
+ const old = deferred();
171
+ response = old.promise;
172
+ await userEvent.click(
173
+ screen.getByRole('button', { name: 'Retry', exact: true }),
174
+ );
175
+ const current = deferred();
176
+ response = current.promise;
177
+ await userEvent.click(
178
+ screen.getByRole('button', { name: 'Change query' }),
179
+ );
180
+ expect(screen.getByRole('status')).toHaveTextContent('Loading');
181
+ expect(screen.queryByText('First row')).not.toBeInTheDocument();
182
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
183
+ await act(async () => current.resolve({ data: ['Second row'] }));
184
+ await screen.findByText('Second row');
185
+ await act(async () => old.resolve({ data: ['Obsolete row'] }));
186
+ expect(screen.queryByText('Obsolete row')).not.toBeInTheDocument();
187
+ expect(screen.getByText('Second row')).toBeVisible();
188
+ });
189
+ },
190
+ );
@@ -117,6 +117,19 @@ const configurationRoutes = [
117
117
  ] as unknown as AppClientRegisteredRoute[];
118
118
 
119
119
  describe('application-owned permission presentation', () => {
120
+ it('omits the generic Page access description and retains other section descriptions', () => {
121
+ const sections = permissionSections(source, [], (value) =>
122
+ typeof value === 'string' ? value : value.key,
123
+ );
124
+
125
+ expect(
126
+ sections.find((section) => section.id === 'pages')?.description,
127
+ ).toBe(undefined);
128
+ expect(
129
+ sections.find((section) => section.id === 'business')?.description,
130
+ ).toBe('settings.businessOperationsHint');
131
+ });
132
+
120
133
  it('can hide a created-record selector while retaining a related-record selector and stored policy', () => {
121
134
  const resource = {
122
135
  type: 'composite',
@@ -5,7 +5,13 @@ import {
5
5
  AuthorizationClient,
6
6
  authorizationClientToken,
7
7
  } from '@nocobase/app-plugin-authorization/client';
8
- import { screen, waitFor, within } from '@testing-library/react';
8
+ import {
9
+ act,
10
+ fireEvent,
11
+ screen,
12
+ waitFor,
13
+ within,
14
+ } from '@testing-library/react';
9
15
  import userEvent from '@testing-library/user-event';
10
16
  import { Routes, Route, useLocation } from 'react-router';
11
17
  import { expect, it, vi } from 'vitest';
@@ -59,6 +65,79 @@ function Location() {
59
65
  );
60
66
  }
61
67
 
68
+ it.each(['fast', 'slow'] as const)(
69
+ 'keeps the user table on drawer close and only indicates a slow refresh (%s)',
70
+ async (speed) => {
71
+ const user = userEvent.setup();
72
+ let refresh = false;
73
+ let complete!: () => void;
74
+ const response = new Promise<void>((resolve) => {
75
+ complete = resolve;
76
+ });
77
+ await renderWithApp(
78
+ <Routes>
79
+ <Route path='/settings/users' element={<UsersPage />}>
80
+ <Route path=':userId' element={<UserDetail />}>
81
+ <Route path='edit' element={<UserForm />} />
82
+ </Route>
83
+ </Route>
84
+ </Routes>,
85
+ {
86
+ route: '/settings/users',
87
+ namespace,
88
+ namespaces,
89
+ services,
90
+ fetch: answerApi(async ({ path }) => {
91
+ if (path === 'authorization/permissions')
92
+ return { data: { unrestricted: true, permissions: [] } };
93
+ if (path === 'users/options') return options;
94
+ if (path === 'users/alice') return { data: row };
95
+ if (refresh) await response;
96
+ return {
97
+ data: [{ ...row, name: refresh ? 'Alice refreshed' : row.name }],
98
+ meta: { total: 1 },
99
+ };
100
+ }),
101
+ },
102
+ );
103
+ await user.click(await screen.findByRole('link', { name: 'Alice' }));
104
+ await waitFor(() =>
105
+ expect(screen.getByRole('textbox', { name: 'Name *' })).toHaveValue(
106
+ 'Alice',
107
+ ),
108
+ );
109
+ refresh = true;
110
+ vi.useFakeTimers();
111
+ try {
112
+ await act(() => vi.advanceTimersByTimeAsync(1000));
113
+ expect(screen.queryByText(enUS.settings.loading)).not.toBeInTheDocument();
114
+ await act(async () => {
115
+ fireEvent.click(
116
+ screen.getByRole('button', { name: 'Cancel', exact: true }),
117
+ );
118
+ });
119
+ expect(screen.getByRole('link', { name: 'Alice' })).toBeVisible();
120
+ expect(screen.queryByText(enUS.settings.loading)).not.toBeInTheDocument();
121
+ if (speed === 'slow') {
122
+ await act(() => vi.advanceTimersByTimeAsync(1000));
123
+ expect(screen.getByText(enUS.settings.loading)).toBeVisible();
124
+ expect(screen.getByRole('link', { name: 'Alice' })).toBeVisible();
125
+ }
126
+ await act(async () => {
127
+ complete();
128
+ await response;
129
+ });
130
+ await act(() => vi.advanceTimersByTimeAsync(1000));
131
+ expect(
132
+ screen.getByRole('link', { name: 'Alice refreshed' }),
133
+ ).toBeVisible();
134
+ expect(screen.queryByText(enUS.settings.loading)).not.toBeInTheDocument();
135
+ } finally {
136
+ vi.useRealTimers();
137
+ }
138
+ },
139
+ );
140
+
62
141
  it('uses role labels and paired server filters, retaining the source and ordering when clearing filters', async () => {
63
142
  const calls = vi.fn(({ path }: { path: string }) =>
64
143
  path === 'authorization/permissions'
@@ -41,7 +41,7 @@ describe.each([
41
41
  ['zh-CN', zhCN],
42
42
  ] as const)('%s', (language, copy) => {
43
43
  it.each(['users', 'roles'] as const)(
44
- 'explains the next step for a read-only empty %s list',
44
+ 'keeps the compact empty status and hides creation for a read-only %s list',
45
45
  async (kind) => {
46
46
  await renderWithApp(
47
47
  <Routes>
@@ -64,13 +64,9 @@ describe.each([
64
64
  }),
65
65
  },
66
66
  );
67
- expect(
68
- await screen.findByText(
69
- kind === 'users'
70
- ? copy.settings.usersReadOnlyEmpty
71
- : copy.settings.rolesReadOnlyEmpty,
72
- ),
73
- ).toBeVisible();
67
+ expect((await screen.findByRole('status')).textContent).toBe(
68
+ copy.listFeedback.empty,
69
+ );
74
70
  expect(
75
71
  screen.queryByRole('button', {
76
72
  name:
@@ -119,13 +115,9 @@ describe.each([
119
115
  kind === 'users' ? copy.settings.newUser : copy.settings.newRole,
120
116
  }),
121
117
  ).toHaveLength(1);
122
- expect(
123
- screen.queryByText(
124
- kind === 'users'
125
- ? copy.settings.usersReadOnlyEmpty
126
- : copy.settings.rolesReadOnlyEmpty,
127
- ),
128
- ).not.toBeInTheDocument();
118
+ expect(screen.getByRole('status').textContent).toBe(
119
+ copy.listFeedback.empty,
120
+ );
129
121
  },
130
122
  );
131
123
 
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" href="data:," />
7
+ <title>Permission alignment verification</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script
12
+ type="module"
13
+ src="/tests/fixtures/permission-alignment.tsx"
14
+ ></script>
15
+ </body>
16
+ </html>