@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.
- package/.agents/rules/components.md +3 -1
- package/.agents/rules/notification-templates.md +5 -0
- package/.agents/rules/shell.md +9 -3
- package/AGENTS.md +4 -2
- package/README.MD +11 -5
- package/client/components/back-button.tsx +37 -12
- package/client/components/body-portal.tsx +25 -0
- package/client/components/brand-loading-mark.tsx +5 -2
- package/client/components/brand-mark-geometry.ts +3 -3
- package/client/components/breadcrumbs.tsx +60 -3
- package/client/components/data-table/data-table.tsx +90 -19
- package/client/components/data-table/pagination.tsx +17 -2
- package/client/components/flow-diagram.tsx +2 -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 +8 -8
- package/client/layouts/components/sidebar-footer.tsx +18 -25
- package/client/lib/navigation-transform.ts +156 -22
- package/client/locales/system/en-US.ts +26 -5
- package/client/locales/system/settings-en-US.ts +17 -0
- package/client/locales/system/settings-zh-CN.ts +15 -0
- package/client/locales/system/zh-CN.ts +26 -5
- package/client/pages/auth/register.tsx +39 -22
- package/client/pages/auth/shared.tsx +1 -3
- package/client/pages/settings/default-access.tsx +1 -1
- package/client/pages/settings/hooks.tsx +3 -68
- package/client/pages/settings/notification-routes.ts +109 -0
- package/client/pages/settings/notification-templates.tsx +26 -0
- package/client/pages/settings/permission-management-page.tsx +15 -90
- package/client/pages/settings/roles.tsx +27 -27
- package/client/pages/settings/routes.ts +107 -101
- package/client/pages/settings/settings-home.tsx +109 -0
- package/client/pages/settings/settings-module-page.tsx +158 -0
- package/client/pages/settings/shell-routes.ts +53 -0
- 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/routes.ts +17 -1
- package/client/routing/app-router.tsx +1 -14
- package/client/routing/client-route.tsx +199 -30
- package/client/routing/navigation-destination.ts +42 -0
- package/client/routing/route-context.tsx +30 -3
- package/client/routing/route-leave-boundary.tsx +261 -0
- package/client/routing/route-navigation.ts +72 -8
- package/client/routing/route-tree.tsx +7 -12
- package/client/runtime.ts +2 -2
- package/client/styles.css +18 -1
- package/config.example.yml +1 -1
- package/package.json +13 -13
- package/server/config/auth.ts +1 -1
- package/server/providers/index.ts +2 -0
- package/server/providers/notification-management.ts +27 -0
- package/tests/client/account-permissions.test.tsx +1 -1
- 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-auth.test.tsx +10 -1
- package/tests/client/client-route-recovery.test.tsx +93 -1
- package/tests/client/client-routes.test.ts +137 -4
- package/tests/client/client-shell.test.tsx +23 -8
- package/tests/client/foundation-integration.test.ts +38 -17
- package/tests/client/layout-i18n.test.tsx +9 -4
- 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 +458 -17
- package/tests/client/primitive-labels.test.tsx +2 -2
- package/tests/client/registration-feedback.test.tsx +143 -0
- 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 +112 -0
- package/tests/client/settings-idle-list.test.tsx +124 -0
- package/tests/client/settings-navigation.test.ts +230 -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
- package/tests/server/notification-templates.test.ts +144 -0
- 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
|
|
2
|
-
import {
|
|
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 {
|
|
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(['
|
|
45
|
-
'uses two URL-backed tabs under one heading
|
|
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('
|
|
89
|
-
await mount('', 'default-access', false);
|
|
90
|
-
await screen.findByRole('
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
94
|
+
await screen.findByRole('status', { name: '正在加载客户' }),
|
|
95
95
|
).toHaveTextContent('正在加载客户');
|
|
96
96
|
});
|
|
97
97
|
|