@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.
- package/.agents/rules/components.md +3 -1
- package/.agents/rules/shell.md +8 -2
- package/AGENTS.md +1 -1
- package/client/components/back-button.tsx +37 -12
- package/client/components/brand-loading-mark.tsx +5 -2
- package/client/components/brand-mark-geometry.ts +3 -3
- package/client/components/breadcrumbs.tsx +47 -3
- package/client/components/data-table/data-table.tsx +90 -19
- package/client/components/data-table/pagination.tsx +17 -2
- package/client/components/list-feedback.tsx +31 -22
- package/client/components/loading.tsx +127 -16
- package/client/components/pending-button.tsx +111 -0
- package/client/components/role-list.tsx +7 -0
- package/client/components/route-child-page.tsx +72 -1
- package/client/components/route-overlay.tsx +149 -15
- package/client/components/task-progress.tsx +155 -0
- package/client/components/use-route-overlay.ts +8 -11
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
- package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
- package/client/extensions/nocobase-workflow-management/README.md +5 -5
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
- package/client/layouts/app-layout.tsx +0 -4
- package/client/locales/system/en-US.ts +19 -0
- package/client/locales/system/zh-CN.ts +19 -0
- package/client/pages/settings/default-access.tsx +1 -1
- package/client/pages/settings/roles.tsx +27 -27
- package/client/pages/settings/routes.ts +15 -7
- package/client/pages/settings/user-detail.tsx +3 -17
- package/client/pages/settings/user-form.tsx +9 -15
- package/client/pages/settings/users.tsx +11 -27
- package/client/routing/client-route.tsx +199 -37
- package/client/routing/navigation-destination.ts +42 -0
- package/client/routing/route-leave-boundary.tsx +13 -1
- package/client/styles.css +18 -1
- package/package.json +8 -8
- package/tests/client/auth-i18n.test.tsx +1 -1
- package/tests/client/authenticated-client-fixture.test.tsx +37 -0
- package/tests/client/back-button.test.tsx +34 -0
- package/tests/client/client-route-recovery.test.tsx +93 -1
- package/tests/client/list-feedback.test.tsx +5 -5
- package/tests/client/list-loading.test.tsx +397 -0
- package/tests/client/loading.test.tsx +20 -11
- package/tests/client/manual-list-retry.test.tsx +2 -2
- package/tests/client/navigation-parent.test.tsx +172 -0
- package/tests/client/pending-button.test.tsx +106 -0
- package/tests/client/permission-management-tabs.test.tsx +48 -12
- package/tests/client/primitive-labels.test.tsx +2 -2
- package/tests/client/role-return.test.tsx +21 -3
- package/tests/client/roles.test.tsx +84 -3
- package/tests/client/route-leave-recheck.test.tsx +74 -0
- package/tests/client/route-presentation.test.tsx +557 -0
- package/tests/client/session-preparation.test.tsx +20 -17
- package/tests/client/settings-continuity.test.tsx +35 -11
- package/tests/client/settings-home.test.tsx +4 -2
- package/tests/client/settings-idle-list.test.tsx +124 -0
- package/tests/client/user-drawer.test.tsx +3 -4
- package/tests/fixtures/dialog-footer.html +12 -0
- package/tests/fixtures/dialog-footer.tsx +215 -0
- package/tests/helpers/authenticated-client.tsx +70 -22
- 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 {
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
?
|
|
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('
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
+
});
|