@nocobase/app-template-default 1.0.0-beta.87 → 1.0.0-beta.89

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 (107) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/notification-templates.md +5 -0
  3. package/.agents/rules/shell.md +9 -3
  4. package/AGENTS.md +4 -2
  5. package/README.MD +11 -5
  6. package/client/components/back-button.tsx +37 -12
  7. package/client/components/body-portal.tsx +25 -0
  8. package/client/components/brand-loading-mark.tsx +5 -2
  9. package/client/components/brand-mark-geometry.ts +3 -3
  10. package/client/components/breadcrumbs.tsx +60 -3
  11. package/client/components/data-table/data-table.tsx +90 -19
  12. package/client/components/data-table/pagination.tsx +17 -2
  13. package/client/components/flow-diagram.tsx +2 -2
  14. package/client/components/list-feedback.tsx +31 -22
  15. package/client/components/loading.tsx +127 -16
  16. package/client/components/pending-button.tsx +111 -0
  17. package/client/components/role-list.tsx +7 -0
  18. package/client/components/route-child-page.tsx +72 -1
  19. package/client/components/route-overlay.tsx +149 -15
  20. package/client/components/task-progress.tsx +155 -0
  21. package/client/components/use-route-overlay.ts +8 -11
  22. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  23. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  29. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  30. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  31. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  32. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  33. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  34. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  35. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  36. package/client/layouts/app-layout.tsx +8 -8
  37. package/client/layouts/components/sidebar-footer.tsx +18 -25
  38. package/client/lib/navigation-transform.ts +156 -22
  39. package/client/locales/system/en-US.ts +26 -5
  40. package/client/locales/system/settings-en-US.ts +17 -0
  41. package/client/locales/system/settings-zh-CN.ts +15 -0
  42. package/client/locales/system/zh-CN.ts +26 -5
  43. package/client/pages/auth/register.tsx +39 -22
  44. package/client/pages/auth/shared.tsx +1 -3
  45. package/client/pages/settings/default-access.tsx +1 -1
  46. package/client/pages/settings/hooks.tsx +3 -68
  47. package/client/pages/settings/notification-routes.ts +109 -0
  48. package/client/pages/settings/notification-templates.tsx +26 -0
  49. package/client/pages/settings/permission-management-page.tsx +15 -90
  50. package/client/pages/settings/roles.tsx +27 -27
  51. package/client/pages/settings/routes.ts +107 -101
  52. package/client/pages/settings/settings-home.tsx +109 -0
  53. package/client/pages/settings/settings-module-page.tsx +158 -0
  54. package/client/pages/settings/shell-routes.ts +53 -0
  55. package/client/pages/settings/user-detail.tsx +3 -17
  56. package/client/pages/settings/user-form.tsx +9 -15
  57. package/client/pages/settings/users.tsx +11 -27
  58. package/client/routes.ts +17 -1
  59. package/client/routing/app-router.tsx +1 -14
  60. package/client/routing/client-route.tsx +199 -30
  61. package/client/routing/navigation-destination.ts +42 -0
  62. package/client/routing/route-context.tsx +30 -3
  63. package/client/routing/route-leave-boundary.tsx +261 -0
  64. package/client/routing/route-navigation.ts +72 -8
  65. package/client/routing/route-tree.tsx +7 -12
  66. package/client/runtime.ts +2 -2
  67. package/client/styles.css +18 -1
  68. package/config.example.yml +1 -1
  69. package/package.json +13 -13
  70. package/server/config/auth.ts +1 -1
  71. package/server/providers/index.ts +2 -0
  72. package/server/providers/notification-management.ts +27 -0
  73. package/tests/client/account-permissions.test.tsx +1 -1
  74. package/tests/client/auth-i18n.test.tsx +1 -1
  75. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  76. package/tests/client/back-button.test.tsx +34 -0
  77. package/tests/client/client-auth.test.tsx +10 -1
  78. package/tests/client/client-route-recovery.test.tsx +93 -1
  79. package/tests/client/client-routes.test.ts +137 -4
  80. package/tests/client/client-shell.test.tsx +23 -8
  81. package/tests/client/foundation-integration.test.ts +38 -17
  82. package/tests/client/layout-i18n.test.tsx +9 -4
  83. package/tests/client/list-feedback.test.tsx +5 -5
  84. package/tests/client/list-loading.test.tsx +397 -0
  85. package/tests/client/loading.test.tsx +20 -11
  86. package/tests/client/manual-list-retry.test.tsx +2 -2
  87. package/tests/client/navigation-parent.test.tsx +172 -0
  88. package/tests/client/pending-button.test.tsx +106 -0
  89. package/tests/client/permission-management-tabs.test.tsx +458 -17
  90. package/tests/client/primitive-labels.test.tsx +2 -2
  91. package/tests/client/registration-feedback.test.tsx +143 -0
  92. package/tests/client/role-return.test.tsx +21 -3
  93. package/tests/client/roles.test.tsx +84 -3
  94. package/tests/client/route-leave-recheck.test.tsx +74 -0
  95. package/tests/client/route-presentation.test.tsx +557 -0
  96. package/tests/client/session-preparation.test.tsx +20 -17
  97. package/tests/client/settings-continuity.test.tsx +35 -11
  98. package/tests/client/settings-home.test.tsx +112 -0
  99. package/tests/client/settings-idle-list.test.tsx +124 -0
  100. package/tests/client/settings-navigation.test.ts +230 -0
  101. package/tests/client/user-drawer.test.tsx +3 -4
  102. package/tests/fixtures/dialog-footer.html +12 -0
  103. package/tests/fixtures/dialog-footer.tsx +215 -0
  104. package/tests/helpers/authenticated-client.tsx +70 -22
  105. package/tests/playwright/dialog-footer.test.ts +227 -0
  106. package/tests/server/notification-templates.test.ts +144 -0
  107. package/tests/client/permission-management-routes.test.ts +0 -64
@@ -0,0 +1,106 @@
1
+ import { createRef } from 'react';
2
+ import { act, fireEvent, render, screen } from '@testing-library/react';
3
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
4
+ import { PendingButton } from '../../client/components/pending-button';
5
+ import { useSubmission } from '@nocobase/app-client';
6
+
7
+ function Editor({ operation }: { readonly operation: () => Promise<void> }) {
8
+ const submission = useSubmission({ scope: 'pending-button-test' });
9
+ return (
10
+ <form
11
+ aria-label='Editor'
12
+ onSubmit={(event) => {
13
+ event.preventDefault();
14
+ void submission
15
+ .run({ name: 'Draft' }, operation)
16
+ .catch(() => undefined);
17
+ }}
18
+ >
19
+ <input aria-label='Name' defaultValue='Draft' />
20
+ <output data-testid='recoverable'>{String(submission.pending)}</output>
21
+ <PendingButton
22
+ type='submit'
23
+ busy={submission.submitting}
24
+ pendingLabel='Saving'
25
+ >
26
+ Save
27
+ </PendingButton>
28
+ </form>
29
+ );
30
+ }
31
+
32
+ beforeEach(() => vi.useFakeTimers());
33
+ afterEach(() => vi.useRealTimers());
34
+ it('locks immediately, preserves native props/ref, announces once and clears on completion', () => {
35
+ const ref = createRef<HTMLButtonElement>();
36
+ const view = render(
37
+ <PendingButton
38
+ ref={ref}
39
+ form='editor'
40
+ type='submit'
41
+ busy={false}
42
+ pendingLabel='Saving changes'
43
+ >
44
+ Save
45
+ </PendingButton>,
46
+ );
47
+ expect(ref.current).toBe(
48
+ screen.getByRole('button', { name: 'Save', exact: true }),
49
+ );
50
+ view.rerender(
51
+ <PendingButton
52
+ ref={ref}
53
+ form='editor'
54
+ type='submit'
55
+ busy
56
+ pendingLabel='Saving changes'
57
+ >
58
+ Save
59
+ </PendingButton>,
60
+ );
61
+ expect(ref.current).toBeDisabled();
62
+ expect(ref.current).toHaveAttribute('aria-busy', 'true');
63
+ expect(ref.current).toHaveAttribute('form', 'editor');
64
+ expect(ref.current).toHaveAttribute('type', 'submit');
65
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
66
+ act(() => vi.advanceTimersByTime(300));
67
+ expect(screen.getAllByRole('status')).toHaveLength(1);
68
+ expect(screen.getByRole('button', { name: 'Saving changes' })).toBeDisabled();
69
+ act(() => vi.advanceTimersByTime(9700));
70
+ expect(screen.getByRole('status')).toHaveTextContent(
71
+ 'This is taking longer than usual',
72
+ );
73
+ view.rerender(
74
+ <PendingButton busy={false} disabled pendingLabel='Saving changes'>
75
+ Save
76
+ </PendingButton>,
77
+ );
78
+ expect(
79
+ screen.getByRole('button', { name: 'Save', exact: true }),
80
+ ).toBeDisabled();
81
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
82
+ });
83
+
84
+ it('shares the submission lock across click and form submission, retains input on failure and permits retry', async () => {
85
+ vi.useRealTimers();
86
+ let reject!: (error: Error) => void;
87
+ const operation = vi.fn(
88
+ () =>
89
+ new Promise<void>((_, fail) => {
90
+ reject = fail;
91
+ }),
92
+ );
93
+ render(<Editor operation={operation} />);
94
+ fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true }));
95
+ fireEvent.submit(screen.getByRole('form', { name: 'Editor' }));
96
+ expect(operation).toHaveBeenCalledTimes(1);
97
+ await act(async () => reject(new Error('Rejected')));
98
+ expect(screen.getByRole('textbox')).toHaveValue('Draft');
99
+ expect(screen.getByTestId('recoverable')).toHaveTextContent('true');
100
+ expect(
101
+ screen.getByRole('button', { name: 'Save', exact: true }),
102
+ ).toBeEnabled();
103
+ fireEvent.submit(screen.getByRole('form', { name: 'Editor' }));
104
+ expect(operation).toHaveBeenCalledTimes(2);
105
+ await act(async () => reject(new Error('Rejected again')));
106
+ });
@@ -1,12 +1,57 @@
1
- import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
2
- import { screen, waitFor } from '@testing-library/react';
1
+ import packageMetadata from '../../package.json' with { type: 'json' };
2
+ import { createAuthenticatedClientRenderer } from '../helpers/authenticated-client.js';
3
+ import { resolveAppClientContributions } from '@nocobase/app-client/plugins';
4
+ import { act, screen, waitFor } from '@testing-library/react';
3
5
  import userEvent from '@testing-library/user-event';
4
- import { Route, Routes, useLocation } from 'react-router';
6
+ import { useState, type ReactNode } from 'react';
7
+ import { RouteLeaveBoundary } from '../../client/routing/route-leave-boundary.js';
8
+ import { Breadcrumbs } from '../../client/components/breadcrumbs.js';
9
+ import { useSettingsData } from '../../client/pages/settings/hooks.js';
10
+ import { authorizationToken } from '@nocobase/app-plugin-authorization/server';
11
+ import { useSettingsLeave } from '../../client/pages/settings/hooks.js';
12
+ import { BodyPortal } from '../../client/components/body-portal.js';
13
+ import { ClientRoute } from '../../client/routing/client-route.js';
14
+ import { authorizationClientToken } from '@nocobase/app-plugin-authorization/client';
15
+ import {
16
+ Link,
17
+ Outlet,
18
+ Route,
19
+ Routes,
20
+ useLocation,
21
+ useNavigate,
22
+ } from 'react-router';
5
23
  import { expect, it } from 'vitest';
6
24
  import { PermissionManagementPage } from '../../client/pages/settings/permission-management-page.js';
25
+ import settingsRoutes from '../../client/pages/settings/routes.js';
26
+ import shellRoutes from '../../client/pages/settings/shell-routes.js';
27
+ import { RouteTreeProvider } from '../../client/routing/route-context.js';
28
+ import { useRouteNavigation } from '../../client/routing/route-navigation.js';
29
+
30
+ const renderAuthenticatedClient = createAuthenticatedClientRenderer();
31
+
32
+ const { routes } = resolveAppClientContributions([
33
+ {
34
+ packageName: packageMetadata.name,
35
+ source: 'application',
36
+ routes: [shellRoutes, settingsRoutes],
37
+ },
38
+ ]);
39
+
40
+ /** The shell's route tree and permission check, as `AppLayout` provides them. */
41
+ function Shell({ children }: { children: ReactNode }) {
42
+ const { denied, loading } = useRouteNavigation(routes);
43
+ return (
44
+ <RouteTreeProvider routes={routes} access={{ denied, loading }}>
45
+ <output aria-label='Permission check'>
46
+ {loading ? 'Checking' : 'Ready'}
47
+ </output>
48
+ <RouteLeaveBoundary hidden={loading}>{children}</RouteLeaveBoundary>
49
+ </RouteTreeProvider>
50
+ );
51
+ }
7
52
 
8
53
  function mount(prefix: string, tab = 'roles', roles = true, locale = 'en-US') {
9
- return renderAuthenticatedClient(tree(prefix), {
54
+ return renderAuthenticatedClient(<Shell>{tree(prefix)}</Shell>, {
10
55
  route: `${prefix}/${tab}`,
11
56
  locale,
12
57
  member: true,
@@ -41,8 +86,8 @@ function tree(prefix: string) {
41
86
  </Routes>
42
87
  );
43
88
  }
44
- it.each(['', '/settings'])(
45
- 'uses two URL-backed tabs under one heading, preserves the existing %s route prefix and keyboard focus',
89
+ it.each(['/settings'])(
90
+ 'uses two URL-backed tabs under one heading at the existing %s URLs and keeps keyboard focus',
46
91
  async (prefix) => {
47
92
  const user = userEvent.setup();
48
93
  await mount(prefix);
@@ -85,23 +130,419 @@ it.each(['', '/settings'])(
85
130
  );
86
131
  },
87
132
  );
88
- it('hides an unreadable tab while keeping direct navigation on the readable default scope page', async () => {
89
- await mount('', 'default-access', false);
90
- await screen.findByRole('tab', { name: 'Default data scopes' });
91
- expect(
92
- screen.queryByRole('tab', { name: 'Roles and permissions' }),
93
- ).not.toBeInTheDocument();
94
- expect(
95
- screen.getByRole('tab', { name: 'Default data scopes' }),
96
- ).toHaveAttribute('aria-selected', 'true');
133
+ it('shows a readable view without a single-tab bar and keeps direct navigation on it', async () => {
134
+ await mount('/settings', 'default-access', false);
135
+ await screen.findByRole('heading', { name: 'Permission management' });
136
+ // The module has one view this viewer may open, so it shows that view without a tab bar.
137
+ await waitFor(() =>
138
+ expect(screen.queryByRole('tablist')).not.toBeInTheDocument(),
139
+ );
140
+ expect(screen.queryByRole('tab')).not.toBeInTheDocument();
97
141
  expect(screen.getByRole('status', { name: 'Route' })).toHaveTextContent(
98
- '/default-access',
142
+ '/settings/default-access',
99
143
  );
100
144
  });
101
145
  it('uses the agreed Chinese menu and tab names', async () => {
102
- await mount('', 'roles', true, 'zh-CN');
146
+ await mount('/settings', 'roles', true, 'zh-CN');
103
147
  await screen.findByRole('tab', { name: '角色与权限' });
104
148
  expect(screen.getByRole('heading', { name: '权限管理' })).toBeVisible();
105
149
  expect(screen.getByRole('tab', { name: '角色与权限' })).toBeVisible();
106
150
  expect(screen.getByRole('tab', { name: '默认数据范围' })).toBeVisible();
107
151
  });
152
+
153
+ function Draft() {
154
+ const [value, setValue] = useState('');
155
+ const leave = useSettingsLeave(value !== '', false);
156
+ return (
157
+ <>
158
+ <input
159
+ aria-label='Draft'
160
+ value={value}
161
+ onChange={(event) => setValue(event.target.value)}
162
+ />
163
+ <BodyPortal>
164
+ <div aria-label='Draft overlay' role='dialog'>
165
+ {value}
166
+ </div>
167
+ </BodyPortal>
168
+ {leave.dialog}
169
+ </>
170
+ );
171
+ }
172
+
173
+ function DraftPage() {
174
+ return (
175
+ <PermissionManagementPage tab='roles'>
176
+ <Draft />
177
+ </PermissionManagementPage>
178
+ );
179
+ }
180
+
181
+ it.each([false, true])(
182
+ 'preserves the mounted draft during permission rechecks (page guard: %s)',
183
+ async (guarded) => {
184
+ let hold = false;
185
+ let release!: () => void;
186
+ const pending = new Promise<void>((resolve) => {
187
+ release = resolve;
188
+ });
189
+ const view = await renderAuthenticatedClient(
190
+ <Shell>
191
+ {guarded ? (
192
+ <ClientRoute
193
+ route={{
194
+ id: 'draft',
195
+ name: 'draft',
196
+ path: '/draft',
197
+ auth: 'required',
198
+ authz: 'unrestricted',
199
+ packageName: packageMetadata.name,
200
+ source: 'application',
201
+ componentLoader: async () => ({ default: DraftPage }),
202
+ }}
203
+ />
204
+ ) : (
205
+ <DraftPage />
206
+ )}
207
+ </Shell>,
208
+ {
209
+ route: '/settings/roles',
210
+ beforeRequest: async (request) => {
211
+ if (
212
+ hold &&
213
+ new URL(request.url).pathname.endsWith('/authorization/permissions')
214
+ )
215
+ await pending;
216
+ },
217
+ },
218
+ );
219
+ await screen.findByRole('tab', { name: 'Roles and permissions' });
220
+ const draft = screen.getByRole('textbox', { name: 'Draft' });
221
+ await userEvent.type(draft, 'Keep this unsaved value');
222
+ try {
223
+ hold = true;
224
+ act(() =>
225
+ view.app.container.resolve(authorizationClientToken).invalidate(),
226
+ );
227
+ await waitFor(() =>
228
+ expect(
229
+ screen.getByRole('status', { name: 'Permission check' }),
230
+ ).toHaveTextContent('Checking'),
231
+ );
232
+ expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
233
+ expect(draft).toBeInTheDocument();
234
+ if (guarded) {
235
+ expect(draft).not.toBeVisible();
236
+ expect(
237
+ screen.queryByRole('dialog', { name: 'Draft overlay' }),
238
+ ).not.toBeInTheDocument();
239
+ }
240
+ expect(draft).toHaveValue('Keep this unsaved value');
241
+ } finally {
242
+ hold = false;
243
+ release();
244
+ }
245
+ await screen.findByRole('tab', { name: 'Roles and permissions' });
246
+ expect(screen.getByRole('textbox', { name: 'Draft' })).toBe(draft);
247
+ expect(draft).toHaveValue('Keep this unsaved value');
248
+ },
249
+ );
250
+
251
+ it.each(['tab', 'breadcrumb'])(
252
+ 'protects an inline module draft when leaving by %s',
253
+ async (destination) => {
254
+ const user = userEvent.setup();
255
+ await renderAuthenticatedClient(
256
+ <Shell>
257
+ <Breadcrumbs />
258
+ <Routes>
259
+ <Route path='/settings/roles' element={<DraftPage />} />
260
+ <Route
261
+ path='/settings/default-access'
262
+ element={<Content tab='default-access' />}
263
+ />
264
+ <Route path='/settings' element={<h1>Settings destination</h1>} />
265
+ </Routes>
266
+ </Shell>,
267
+ { route: '/settings/roles' },
268
+ );
269
+ const draft = await screen.findByRole('textbox', { name: 'Draft' });
270
+ await user.type(draft, 'Unsaved inline draft');
271
+ await waitFor(() =>
272
+ expect(
273
+ screen.getByRole('status', { name: 'Permission check' }),
274
+ ).toHaveTextContent('Ready'),
275
+ );
276
+ const target =
277
+ destination === 'tab'
278
+ ? await screen.findByRole('tab', { name: 'Default data scopes' })
279
+ : await screen.findByRole('link', { name: 'System settings' });
280
+ await user.click(target);
281
+ expect(await screen.findByRole('alertdialog')).toBeVisible();
282
+ await user.click(
283
+ screen.getByRole('button', { name: 'Cancel', exact: true }),
284
+ );
285
+ expect(draft).toHaveValue('Unsaved inline draft');
286
+ await user.click(target);
287
+ await user.click(
288
+ await screen.findByRole('button', { name: 'Confirm', exact: true }),
289
+ );
290
+ if (destination === 'tab')
291
+ await waitFor(() =>
292
+ expect(screen.getByRole('status', { name: 'Route' })).toHaveTextContent(
293
+ '/settings/default-access',
294
+ ),
295
+ );
296
+ else
297
+ expect(
298
+ await screen.findByRole('heading', { name: 'Settings destination' }),
299
+ ).toBeVisible();
300
+ expect(
301
+ screen.queryByRole('textbox', { name: 'Draft' }),
302
+ ).not.toBeInTheDocument();
303
+ },
304
+ );
305
+
306
+ function HistoryControls() {
307
+ const navigate = useNavigate();
308
+ const location = useLocation();
309
+ return (
310
+ <>
311
+ <button onClick={() => navigate(-1)}>Browser back</button>
312
+ <button onClick={() => navigate('/settings')}>Open settings</button>
313
+ <output aria-label='Current URL'>{location.pathname}</output>
314
+ </>
315
+ );
316
+ }
317
+ function NestedDraft() {
318
+ const request = useSettingsData<{ data: unknown[] }>('users');
319
+ return (
320
+ <>
321
+ <DraftPage />
322
+ <output aria-label='Loaded users'>
323
+ {request.data ? 'Ready' : 'Loading'}
324
+ </output>
325
+ </>
326
+ );
327
+ }
328
+ const nestedRoute = {
329
+ id: 'nested-draft',
330
+ name: 'Nested draft',
331
+ path: '/settings/roles/test/edit',
332
+ auth: 'required',
333
+ authz: {
334
+ resource: { type: 'settings', id: 'authorization.permission-sets' },
335
+ action: 'read',
336
+ },
337
+ packageName: packageMetadata.name,
338
+ source: 'application',
339
+ componentLoader: async () => ({ default: NestedDraft }),
340
+ } as const;
341
+ const parentRoute = {
342
+ ...nestedRoute,
343
+ id: 'parent-draft',
344
+ path: '/settings/roles',
345
+ componentLoader: async () => ({ default: Outlet }),
346
+ };
347
+ function nestedTree() {
348
+ return (
349
+ <Shell>
350
+ <Breadcrumbs />
351
+ <HistoryControls />
352
+ <Routes>
353
+ <Route
354
+ path='/settings'
355
+ element={
356
+ <>
357
+ <h1>Settings destination</h1>
358
+ <Link to='/settings/roles/test/edit'>Edit role</Link>
359
+ </>
360
+ }
361
+ />
362
+ <Route
363
+ path='/settings/roles'
364
+ element={<ClientRoute route={parentRoute} />}
365
+ >
366
+ <Route
367
+ path='test/edit'
368
+ element={<ClientRoute route={nestedRoute} />}
369
+ />
370
+ </Route>
371
+ </Routes>
372
+ </Shell>
373
+ );
374
+ }
375
+ function beforeUnload() {
376
+ const event = new Event('beforeunload', { cancelable: true });
377
+ window.dispatchEvent(event);
378
+ return event.defaultPrevented;
379
+ }
380
+
381
+ it.each(['breadcrumb', 'back'])(
382
+ 'protects a nested draft when leaving across a permission recheck: %s',
383
+ async (destination) => {
384
+ const pending = Promise.withResolvers<void>();
385
+ let hold = false;
386
+ const view = await renderAuthenticatedClient(nestedTree(), {
387
+ route: '/settings',
388
+ beforeRequest: (request) =>
389
+ hold && request.url.endsWith('/authorization/permissions')
390
+ ? pending.promise
391
+ : undefined,
392
+ });
393
+ const user = userEvent.setup();
394
+ await user.click(screen.getByRole('link', { name: 'Edit role' }));
395
+ const draft = await screen.findByRole('textbox', { name: 'Draft' });
396
+ await user.type(draft, 'Nested unsaved draft');
397
+ hold = true;
398
+ act(() =>
399
+ view.app.container.resolve(authorizationClientToken).invalidate(),
400
+ );
401
+ try {
402
+ await waitFor(() => expect(draft).not.toBeVisible());
403
+ expect(beforeUnload()).toBe(true);
404
+ expect(
405
+ screen.queryByRole('link', { name: 'System settings' }),
406
+ ).not.toBeInTheDocument();
407
+ // Permission-sensitive breadcrumbs are absent during the check. Exercise
408
+ // the same destination through the router, then the real breadcrumb after recovery.
409
+ const leave = async (duringRecheck = false) => {
410
+ await user.click(
411
+ destination === 'breadcrumb'
412
+ ? duringRecheck
413
+ ? screen.getByRole('button', { name: 'Open settings' })
414
+ : await screen.findByRole('link', { name: 'System settings' })
415
+ : screen.getByRole('button', { name: 'Browser back' }),
416
+ );
417
+ };
418
+ await leave(true);
419
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
420
+ expect(
421
+ screen.getByRole('status', { name: 'Current URL' }),
422
+ ).toHaveTextContent('/settings/roles/test/edit');
423
+ expect(beforeUnload()).toBe(true);
424
+ hold = false;
425
+ pending.resolve();
426
+ await waitFor(() => expect(draft).toBeVisible());
427
+ expect(draft).toHaveValue('Nested unsaved draft');
428
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
429
+ await leave();
430
+ expect(await screen.findByRole('alertdialog')).toBeVisible();
431
+ await user.click(
432
+ screen.getByRole('button', { name: 'Cancel', exact: true }),
433
+ );
434
+ expect(
435
+ screen.getByRole('status', { name: 'Current URL' }),
436
+ ).toHaveTextContent('/settings/roles/test/edit');
437
+ expect(draft).toHaveValue('Nested unsaved draft');
438
+ expect(beforeUnload()).toBe(true);
439
+ await leave();
440
+ await user.click(
441
+ screen.getByRole('button', { name: 'Confirm', exact: true }),
442
+ );
443
+ expect(
444
+ await screen.findByRole('heading', { name: 'Settings destination' }),
445
+ ).toBeVisible();
446
+ expect(
447
+ screen.getByRole('status', { name: 'Current URL' }),
448
+ ).toHaveTextContent(/^\/settings$/);
449
+ await waitFor(() => expect(beforeUnload()).toBe(false));
450
+ } finally {
451
+ hold = false;
452
+ pending.resolve();
453
+ }
454
+ },
455
+ );
456
+
457
+ it('restores the draft and restarts only the mounted data effect after permission revalidation', async () => {
458
+ const requests: string[] = [];
459
+ const pending = Promise.withResolvers<void>();
460
+ let hold = false;
461
+ const view = await renderAuthenticatedClient(nestedTree(), {
462
+ route: '/settings/roles/test/edit',
463
+ beforeRequest(request) {
464
+ requests.push(new URL(request.url).pathname);
465
+ if (hold && request.url.endsWith('/authorization/permissions'))
466
+ return pending.promise;
467
+ },
468
+ });
469
+ const draft = await screen.findByRole('textbox', { name: 'Draft' });
470
+ await screen.findByText('Ready', { selector: '[aria-label="Loaded users"]' });
471
+ await userEvent.type(draft, 'Retained after cancel');
472
+ const count = (suffix: string) =>
473
+ requests.filter((path) => path.endsWith(suffix)).length;
474
+ const permissions = count('/authorization/permissions');
475
+ const users = count('/users');
476
+ hold = true;
477
+ act(() => view.app.container.resolve(authorizationClientToken).invalidate());
478
+ try {
479
+ await waitFor(() => expect(draft).not.toBeVisible());
480
+ expect(count('/users')).toBe(users);
481
+ expect(
482
+ screen.queryByRole('link', { name: 'System settings' }),
483
+ ).not.toBeInTheDocument();
484
+ await userEvent.click(
485
+ screen.getByRole('button', { name: 'Open settings' }),
486
+ );
487
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
488
+ expect(
489
+ screen.getByRole('status', { name: 'Current URL' }),
490
+ ).toHaveTextContent('/settings/roles/test/edit');
491
+ expect(beforeUnload()).toBe(true);
492
+ } finally {
493
+ hold = false;
494
+ pending.resolve();
495
+ }
496
+ await waitFor(() => expect(draft).toBeVisible());
497
+ expect(screen.getByRole('textbox', { name: 'Draft' })).toBe(draft);
498
+ expect(draft).toHaveValue('Retained after cancel');
499
+ await waitFor(() => expect(count('/users')).toBe(users + 1));
500
+ expect(count('/authorization/permissions')).toBe(permissions + 1);
501
+ expect(beforeUnload()).toBe(true);
502
+ });
503
+
504
+ it('drops a hidden draft and its shell guard when the final permission result denies access', async () => {
505
+ const pending = Promise.withResolvers<void>();
506
+ let hold = false;
507
+ const view = await renderAuthenticatedClient(nestedTree(), {
508
+ route: '/settings/roles/test/edit',
509
+ member: true,
510
+ grants: [
511
+ {
512
+ resource: { type: 'settings', id: 'authorization.permission-sets' },
513
+ actions: [{ action: 'read' }],
514
+ },
515
+ ],
516
+ beforeRequest: (request) =>
517
+ hold && request.url.endsWith('/authorization/permissions')
518
+ ? pending.promise
519
+ : undefined,
520
+ });
521
+ const draft = await screen.findByRole('textbox', { name: 'Draft' });
522
+ await userEvent.type(draft, 'Must be discarded on denial');
523
+ hold = true;
524
+ act(() => view.app.container.resolve(authorizationClientToken).invalidate());
525
+ try {
526
+ await waitFor(() => expect(draft).not.toBeVisible());
527
+ expect(beforeUnload()).toBe(true);
528
+ await view.server.application.container
529
+ .resolve(authorizationToken)
530
+ .permissionSets.update(view.permissionSetKey, {
531
+ key: view.permissionSetKey,
532
+ grants: [],
533
+ });
534
+ } finally {
535
+ hold = false;
536
+ pending.resolve();
537
+ }
538
+ expect(await screen.findByText('Access denied')).toBeVisible();
539
+ expect(draft).not.toBeInTheDocument();
540
+ await waitFor(() => expect(beforeUnload()).toBe(false));
541
+ await userEvent.click(
542
+ await screen.findByRole('link', { name: 'System settings' }),
543
+ );
544
+ expect(
545
+ await screen.findByRole('heading', { name: 'Settings destination' }),
546
+ ).toBeVisible();
547
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
548
+ });
@@ -82,7 +82,7 @@ describe('labels built into the shipped primitives', () => {
82
82
  expect(close).toHaveAttribute('data-slot', 'toast-close');
83
83
  });
84
84
 
85
- it('labels brand loading and allows decorative inline indicators', () => {
85
+ it('labels brand loading and allows decorative inline indicators', async () => {
86
86
  const { rerender } = render(<BrandSpinner />, { wrapper: I18n });
87
87
  expect(
88
88
  screen.getByRole('status', { name: zhCN['status.loading'] }),
@@ -91,7 +91,7 @@ describe('labels built into the shipped primitives', () => {
91
91
  expect(screen.queryByRole('status')).not.toBeInTheDocument();
92
92
  rerender(<Loading label='正在加载客户' />);
93
93
  expect(
94
- screen.getByRole('status', { name: '正在加载客户' }),
94
+ await screen.findByRole('status', { name: '正在加载客户' }),
95
95
  ).toHaveTextContent('正在加载客户');
96
96
  });
97
97