@nocobase/app-template-default 1.0.0-beta.88 → 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 (70) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/shell.md +8 -2
  3. package/AGENTS.md +1 -1
  4. package/client/components/back-button.tsx +37 -12
  5. package/client/components/brand-loading-mark.tsx +5 -2
  6. package/client/components/brand-mark-geometry.ts +3 -3
  7. package/client/components/breadcrumbs.tsx +47 -3
  8. package/client/components/data-table/data-table.tsx +90 -19
  9. package/client/components/data-table/pagination.tsx +17 -2
  10. package/client/components/list-feedback.tsx +31 -22
  11. package/client/components/loading.tsx +127 -16
  12. package/client/components/pending-button.tsx +111 -0
  13. package/client/components/role-list.tsx +7 -0
  14. package/client/components/route-child-page.tsx +72 -1
  15. package/client/components/route-overlay.tsx +149 -15
  16. package/client/components/task-progress.tsx +155 -0
  17. package/client/components/use-route-overlay.ts +8 -11
  18. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  19. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  20. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  21. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  22. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  23. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  29. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  30. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  31. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  32. package/client/layouts/app-layout.tsx +0 -4
  33. package/client/locales/system/en-US.ts +19 -0
  34. package/client/locales/system/zh-CN.ts +19 -0
  35. package/client/pages/settings/default-access.tsx +1 -1
  36. package/client/pages/settings/roles.tsx +27 -27
  37. package/client/pages/settings/routes.ts +15 -7
  38. package/client/pages/settings/user-detail.tsx +3 -17
  39. package/client/pages/settings/user-form.tsx +9 -15
  40. package/client/pages/settings/users.tsx +11 -27
  41. package/client/routing/client-route.tsx +199 -37
  42. package/client/routing/navigation-destination.ts +42 -0
  43. package/client/routing/route-leave-boundary.tsx +13 -1
  44. package/client/styles.css +18 -1
  45. package/package.json +8 -8
  46. package/tests/client/auth-i18n.test.tsx +1 -1
  47. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  48. package/tests/client/back-button.test.tsx +34 -0
  49. package/tests/client/client-route-recovery.test.tsx +93 -1
  50. package/tests/client/list-feedback.test.tsx +5 -5
  51. package/tests/client/list-loading.test.tsx +397 -0
  52. package/tests/client/loading.test.tsx +20 -11
  53. package/tests/client/manual-list-retry.test.tsx +2 -2
  54. package/tests/client/navigation-parent.test.tsx +172 -0
  55. package/tests/client/pending-button.test.tsx +106 -0
  56. package/tests/client/permission-management-tabs.test.tsx +48 -12
  57. package/tests/client/primitive-labels.test.tsx +2 -2
  58. package/tests/client/role-return.test.tsx +21 -3
  59. package/tests/client/roles.test.tsx +84 -3
  60. package/tests/client/route-leave-recheck.test.tsx +74 -0
  61. package/tests/client/route-presentation.test.tsx +557 -0
  62. package/tests/client/session-preparation.test.tsx +20 -17
  63. package/tests/client/settings-continuity.test.tsx +35 -11
  64. package/tests/client/settings-home.test.tsx +4 -2
  65. package/tests/client/settings-idle-list.test.tsx +124 -0
  66. package/tests/client/user-drawer.test.tsx +3 -4
  67. package/tests/fixtures/dialog-footer.html +12 -0
  68. package/tests/fixtures/dialog-footer.tsx +215 -0
  69. package/tests/helpers/authenticated-client.tsx +70 -22
  70. package/tests/playwright/dialog-footer.test.ts +227 -0
@@ -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,5 +1,5 @@
1
1
  import packageMetadata from '../../package.json' with { type: 'json' };
2
- import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
2
+ import { createAuthenticatedClientRenderer } from '../helpers/authenticated-client.js';
3
3
  import { resolveAppClientContributions } from '@nocobase/app-client/plugins';
4
4
  import { act, screen, waitFor } from '@testing-library/react';
5
5
  import userEvent from '@testing-library/user-event';
@@ -27,6 +27,8 @@ import shellRoutes from '../../client/pages/settings/shell-routes.js';
27
27
  import { RouteTreeProvider } from '../../client/routing/route-context.js';
28
28
  import { useRouteNavigation } from '../../client/routing/route-navigation.js';
29
29
 
30
+ const renderAuthenticatedClient = createAuthenticatedClientRenderer();
31
+
30
32
  const { routes } = resolveAppClientContributions([
31
33
  {
32
34
  packageName: packageMetadata.name,
@@ -266,10 +268,15 @@ it.each(['tab', 'breadcrumb'])(
266
268
  );
267
269
  const draft = await screen.findByRole('textbox', { name: 'Draft' });
268
270
  await user.type(draft, 'Unsaved inline draft');
271
+ await waitFor(() =>
272
+ expect(
273
+ screen.getByRole('status', { name: 'Permission check' }),
274
+ ).toHaveTextContent('Ready'),
275
+ );
269
276
  const target =
270
277
  destination === 'tab'
271
278
  ? await screen.findByRole('tab', { name: 'Default data scopes' })
272
- : screen.getByRole('link', { name: 'System settings' });
279
+ : await screen.findByRole('link', { name: 'System settings' });
273
280
  await user.click(target);
274
281
  expect(await screen.findByRole('alertdialog')).toBeVisible();
275
282
  await user.click(
@@ -302,6 +309,7 @@ function HistoryControls() {
302
309
  return (
303
310
  <>
304
311
  <button onClick={() => navigate(-1)}>Browser back</button>
312
+ <button onClick={() => navigate('/settings')}>Open settings</button>
305
313
  <output aria-label='Current URL'>{location.pathname}</output>
306
314
  </>
307
315
  );
@@ -371,7 +379,7 @@ function beforeUnload() {
371
379
  }
372
380
 
373
381
  it.each(['breadcrumb', 'back'])(
374
- 'protects a nested hidden draft during a pending permission request: %s',
382
+ 'protects a nested draft when leaving across a permission recheck: %s',
375
383
  async (destination) => {
376
384
  const pending = Promise.withResolvers<void>();
377
385
  let hold = false;
@@ -393,15 +401,33 @@ it.each(['breadcrumb', 'back'])(
393
401
  try {
394
402
  await waitFor(() => expect(draft).not.toBeVisible());
395
403
  expect(beforeUnload()).toBe(true);
396
- const leave = async () => {
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) => {
397
410
  await user.click(
398
411
  destination === 'breadcrumb'
399
- ? screen.getByRole('link', { name: 'System settings' })
412
+ ? duringRecheck
413
+ ? screen.getByRole('button', { name: 'Open settings' })
414
+ : await screen.findByRole('link', { name: 'System settings' })
400
415
  : screen.getByRole('button', { name: 'Browser back' }),
401
416
  );
402
- expect(await screen.findByRole('alertdialog')).toBeVisible();
403
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();
404
429
  await leave();
430
+ expect(await screen.findByRole('alertdialog')).toBeVisible();
405
431
  await user.click(
406
432
  screen.getByRole('button', { name: 'Cancel', exact: true }),
407
433
  );
@@ -452,12 +478,17 @@ it('restores the draft and restarts only the mounted data effect after permissio
452
478
  try {
453
479
  await waitFor(() => expect(draft).not.toBeVisible());
454
480
  expect(count('/users')).toBe(users);
481
+ expect(
482
+ screen.queryByRole('link', { name: 'System settings' }),
483
+ ).not.toBeInTheDocument();
455
484
  await userEvent.click(
456
- screen.getByRole('link', { name: 'System settings' }),
457
- );
458
- await userEvent.click(
459
- await screen.findByRole('button', { name: 'Cancel', exact: true }),
485
+ screen.getByRole('button', { name: 'Open settings' }),
460
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);
461
492
  } finally {
462
493
  hold = false;
463
494
  pending.resolve();
@@ -496,7 +527,10 @@ it('drops a hidden draft and its shell guard when the final permission result de
496
527
  expect(beforeUnload()).toBe(true);
497
528
  await view.server.application.container
498
529
  .resolve(authorizationToken)
499
- .permissionSets.update('test-access', { key: 'test-access', grants: [] });
530
+ .permissionSets.update(view.permissionSetKey, {
531
+ key: view.permissionSetKey,
532
+ grants: [],
533
+ });
500
534
  } finally {
501
535
  hold = false;
502
536
  pending.resolve();
@@ -504,7 +538,9 @@ it('drops a hidden draft and its shell guard when the final permission result de
504
538
  expect(await screen.findByText('Access denied')).toBeVisible();
505
539
  expect(draft).not.toBeInTheDocument();
506
540
  await waitFor(() => expect(beforeUnload()).toBe(false));
507
- await userEvent.click(screen.getByRole('link', { name: 'System settings' }));
541
+ await userEvent.click(
542
+ await screen.findByRole('link', { name: 'System settings' }),
543
+ );
508
544
  expect(
509
545
  await screen.findByRole('heading', { name: 'Settings destination' }),
510
546
  ).toBeVisible();
@@ -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
 
@@ -1,3 +1,5 @@
1
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
+ import { RouteTreeProvider } from '../../client/routing/route-context.js';
1
3
  import { renderSettingsClient as renderWithApp } from '../helpers/settings-client.js';
2
4
  import { answerApi } from '@nocobase/app-testing/client';
3
5
  import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
@@ -14,8 +16,24 @@ import enUS from '../../client/locales/en-US.js';
14
16
  import zhCN from '../../client/locales/zh-CN.js';
15
17
 
16
18
  const namespace = '@nocobase/role-return-test';
17
- function Parent() {
18
- return <Outlet context={{ reload: () => undefined }} />;
19
+ function Parent({ path }: { path: string }) {
20
+ const routes: AppClientRegisteredRoute[] = [
21
+ {
22
+ id: 'roles',
23
+ name: 'roles',
24
+ path,
25
+ packageName: namespace,
26
+ source: 'application',
27
+ auth: 'required',
28
+ authz: 'skip',
29
+ componentLoader: async () => ({ default: () => null }),
30
+ },
31
+ ];
32
+ return (
33
+ <RouteTreeProvider routes={routes}>
34
+ <Outlet context={{ reload: () => undefined }} />
35
+ </RouteTreeProvider>
36
+ );
19
37
  }
20
38
  function Location() {
21
39
  const { pathname, search } = useLocation();
@@ -50,7 +68,7 @@ it.each(
50
68
  const result = await renderWithApp(
51
69
  <>
52
70
  <Routes>
53
- <Route path={parent} element={<Parent />}>
71
+ <Route path={parent} element={<Parent path={parent} />}>
54
72
  <Route index element={<h1>Role list</h1>} />
55
73
  <Route path=':key/edit' element={<RoleForm />} />
56
74
  </Route>
@@ -4,10 +4,10 @@ import {
4
4
  AuthorizationClient,
5
5
  authorizationClientToken,
6
6
  } from '@nocobase/app-plugin-authorization/client';
7
- import { screen, waitFor, within } from '@testing-library/react';
7
+ import { act, screen, waitFor, within } from '@testing-library/react';
8
8
  import userEvent from '@testing-library/user-event';
9
- import { Routes, Route } from 'react-router';
10
- import { expect, it } from 'vitest';
9
+ import { Routes, Route, useNavigate } from 'react-router';
10
+ import { expect, it, vi } from 'vitest';
11
11
  import RolesPage from '../../client/pages/settings/roles.js';
12
12
  import enUS from '../../client/locales/en-US.js';
13
13
  import zhCN from '../../client/locales/zh-CN.js';
@@ -83,3 +83,84 @@ it('keeps the standard role table sorted, searchable and linked to the current q
83
83
  '/settings/roles/cp-customer/edit?q=Customer',
84
84
  );
85
85
  });
86
+
87
+ function Navigate({ to, label }: { to: string; label: string }) {
88
+ const navigate = useNavigate();
89
+ return (
90
+ <button type='button' onClick={() => void navigate(to)}>
91
+ {label}
92
+ </button>
93
+ );
94
+ }
95
+
96
+ it('announces a slow retry after a refresh failure once, from the retry button', async () => {
97
+ const user = userEvent.setup();
98
+ const roles = { data: [{ key: 'team', title: 'NocoBase team', grants: [] }] };
99
+ let reads = 0;
100
+ let finish!: () => void;
101
+ const slow = new Promise<void>((resolve) => {
102
+ finish = resolve;
103
+ });
104
+ await renderWithApp(
105
+ <>
106
+ <Routes>
107
+ <Route path='/settings/roles' element={<RolesPage />}>
108
+ <Route
109
+ path='covered'
110
+ element={<Navigate to='/settings/roles' label='Back to roles' />}
111
+ />
112
+ </Route>
113
+ </Routes>
114
+ <Navigate to='/settings/roles/covered' label='Cover roles' />
115
+ </>,
116
+ {
117
+ route: '/settings/roles',
118
+ namespace: settingsNamespace,
119
+ namespaces,
120
+ services,
121
+ fetch: answerApi(async ({ path }) => {
122
+ if (path === 'authorization/permissions')
123
+ return { data: { unrestricted: true, permissions: [] } };
124
+ reads += 1;
125
+ if (reads === 2)
126
+ return Response.json(
127
+ { error: { status: 'INTERNAL', message: 'Unavailable' } },
128
+ { status: 500 },
129
+ );
130
+ if (reads === 3) await slow;
131
+ return roles;
132
+ }),
133
+ },
134
+ );
135
+ await screen.findByTitle('NocoBase team');
136
+ // Returning from a covering route reads the list again; that read fails.
137
+ await user.click(screen.getByRole('button', { name: 'Cover roles' }));
138
+ await user.click(
139
+ await screen.findByRole('button', { name: 'Back to roles' }),
140
+ );
141
+ await screen.findByText(enUS.listFeedback.refreshFailed);
142
+ vi.useFakeTimers();
143
+ try {
144
+ await act(async () => {
145
+ screen.getByRole('button', { name: enUS.listFeedback.retry }).click();
146
+ });
147
+ await act(() => vi.advanceTimersByTimeAsync(300));
148
+ expect(screen.getAllByRole('status')).toHaveLength(1);
149
+ expect(
150
+ screen.getByRole('button', { name: enUS.listFeedback.retrying }),
151
+ ).toBeDisabled();
152
+ expect(screen.getByTitle('NocoBase team')).toBeVisible();
153
+ await act(async () => {
154
+ finish();
155
+ await vi.advanceTimersByTimeAsync(0);
156
+ });
157
+ } finally {
158
+ vi.useRealTimers();
159
+ }
160
+ await waitFor(() =>
161
+ expect(
162
+ screen.queryByText(enUS.listFeedback.refreshFailed),
163
+ ).not.toBeInTheDocument(),
164
+ );
165
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
166
+ });
@@ -0,0 +1,74 @@
1
+ import { Activity, useState, useLayoutEffect } from 'react';
2
+ import { Link, Route, Routes, useLocation } from 'react-router';
3
+ import { renderWithApp } from '@nocobase/app-testing/client';
4
+ import { act, screen, waitFor, within } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import { expect, it } from 'vitest';
7
+ import {
8
+ RouteLeaveBoundary,
9
+ usePageLeave,
10
+ } from '../../client/routing/route-leave-boundary.js';
11
+ import enUS from '../../client/locales/en-US.js';
12
+ import metadata from '../../package.json' with { type: 'json' };
13
+
14
+ it('cancels a shell leave confirmation on concealment and requires a fresh decision after recovery', async () => {
15
+ let conceal: (hidden: boolean) => void;
16
+ function Editor() {
17
+ const leave = usePageLeave(true, false);
18
+ return (
19
+ <>
20
+ <p>Unsaved editor</p>
21
+ {leave.dialog}
22
+ </>
23
+ );
24
+ }
25
+ function Harness() {
26
+ const [hidden, setHidden] = useState(false);
27
+ useLayoutEffect(() => {
28
+ conceal = setHidden;
29
+ }, []);
30
+ return (
31
+ <RouteLeaveBoundary hidden={hidden}>
32
+ <Link to='/other'>Leave page</Link>
33
+ <output data-testid='location'>{useLocation().pathname}</output>
34
+ <Routes>
35
+ <Route
36
+ path='/editor'
37
+ element={
38
+ <Activity mode={hidden ? 'hidden' : 'visible'}>
39
+ <Editor />
40
+ </Activity>
41
+ }
42
+ />
43
+ <Route path='/other' element={<p>Other page</p>} />
44
+ </Routes>
45
+ </RouteLeaveBoundary>
46
+ );
47
+ }
48
+ const user = userEvent.setup();
49
+ await renderWithApp(<Harness />, {
50
+ route: '/editor',
51
+ namespace: metadata.name,
52
+ namespaces: { [metadata.name]: enUS },
53
+ });
54
+ await user.click(screen.getByRole('link', { name: 'Leave page' }));
55
+ await screen.findByRole('alertdialog');
56
+ await act(async () => conceal(true));
57
+ expect(screen.queryByRole('alertdialog')).toBeNull();
58
+ await user.click(screen.getByRole('link', { name: 'Leave page' }));
59
+ expect(screen.getByTestId('location')).toHaveTextContent('/editor');
60
+ const unload = new Event('beforeunload', { cancelable: true });
61
+ window.dispatchEvent(unload);
62
+ expect(unload.defaultPrevented).toBe(true);
63
+ await act(async () => conceal(false));
64
+ expect(screen.getByText('Unsaved editor')).toBeVisible();
65
+ expect(screen.getByTestId('location')).toHaveTextContent('/editor');
66
+ expect(screen.queryByRole('alertdialog')).toBeNull();
67
+ await user.click(screen.getByRole('link', { name: 'Leave page' }));
68
+ const question = await screen.findByRole('alertdialog');
69
+ await user.click(within(question).getByRole('button', { name: 'Confirm' }));
70
+ await waitFor(() => expect(screen.getByText('Other page')).toBeVisible());
71
+ const after = new Event('beforeunload', { cancelable: true });
72
+ window.dispatchEvent(after);
73
+ expect(after.defaultPrevented).toBe(false);
74
+ });