@nocobase/app-template-default 1.0.0-beta.86 → 1.0.0-beta.88
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/notification-templates.md +5 -0
- package/.agents/rules/shell.md +4 -2
- package/AGENTS.md +3 -1
- package/README.MD +11 -5
- package/client/components/body-portal.tsx +25 -0
- package/client/components/breadcrumbs.tsx +13 -0
- package/client/components/flow-diagram.tsx +2 -2
- package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +1 -1
- package/client/layouts/app-layout.tsx +8 -4
- package/client/layouts/components/header-actions.tsx +0 -2
- package/client/layouts/components/sidebar-footer.tsx +18 -25
- package/client/layouts/components/user-menu.tsx +6 -13
- package/client/lib/navigation-transform.ts +156 -22
- package/client/locales/system/en-US.ts +8 -6
- 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 +8 -6
- package/client/pages/auth/register.tsx +39 -22
- package/client/pages/auth/shared.tsx +1 -3
- 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/routes.ts +96 -98
- 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/routes.ts +17 -1
- package/client/routing/app-router.tsx +1 -14
- package/client/routing/client-route.tsx +16 -9
- package/client/routing/route-context.tsx +30 -3
- package/client/routing/route-leave-boundary.tsx +249 -0
- package/client/routing/route-navigation.ts +72 -8
- package/client/routing/route-tree.tsx +7 -12
- package/client/routing/standalone-page-layout.tsx +0 -5
- package/client/runtime.ts +2 -2
- package/client/theme/theme-settings.tsx +60 -162
- package/config.example.yml +51 -32
- package/package.json +28 -28
- package/server/config/auth.ts +2 -2
- package/server/config/index.ts +3 -0
- package/server/config/mailers.ts +11 -0
- 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/client-auth.test.tsx +10 -1
- package/tests/client/client-routes.test.ts +137 -4
- package/tests/client/client-shell.test.tsx +29 -10
- package/tests/client/client-theme.test.tsx +118 -39
- package/tests/client/foundation-integration.test.ts +38 -17
- package/tests/client/header-hover.test.tsx +33 -5
- package/tests/client/layout-i18n.test.tsx +9 -4
- package/tests/client/permission-management-tabs.test.tsx +421 -16
- package/tests/client/registration-feedback.test.tsx +143 -0
- package/tests/client/settings-home.test.tsx +110 -0
- package/tests/client/settings-navigation.test.ts +230 -0
- package/tests/client/standalone-page-layout.test.tsx +45 -0
- package/tests/client/user-menu-sign-out.test.tsx +38 -14
- package/tests/client/user-menu.test.tsx +65 -0
- package/tests/fixtures/theme-tokens.tsx +13 -1
- package/tests/server/auth-email-config.test.ts +51 -17
- package/tests/server/notification-templates.test.ts +144 -0
- package/tests/client/permission-management-routes.test.ts +0 -64
|
@@ -2,7 +2,7 @@ import { fireEvent, screen } from '@testing-library/react';
|
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
4
4
|
import { AppThemeProvider } from '../../client/theme/theme-provider';
|
|
5
|
-
import {
|
|
5
|
+
import { HeaderActions } from '../../client/layouts/components/header-actions';
|
|
6
6
|
import { UserMenu } from '../../client/layouts/components/user-menu';
|
|
7
7
|
|
|
8
8
|
import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
|
|
@@ -24,10 +24,7 @@ afterEach(() => {
|
|
|
24
24
|
document.documentElement.removeAttribute('data-theme');
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
const panels = [
|
|
28
|
-
['Appearance', ThemeSettings, 'dialog'],
|
|
29
|
-
['Open account menu', UserMenu, 'menu'],
|
|
30
|
-
] as const;
|
|
27
|
+
const panels = [['Open account menu', UserMenu, 'menu']] as const;
|
|
31
28
|
|
|
32
29
|
it.each(panels)(
|
|
33
30
|
'opens and closes %s on hover',
|
|
@@ -75,3 +72,34 @@ it.each(panels)(
|
|
|
75
72
|
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
76
73
|
},
|
|
77
74
|
);
|
|
75
|
+
|
|
76
|
+
it('offers appearance only inside the account menu with keyboard selection', async () => {
|
|
77
|
+
const user = userEvent.setup();
|
|
78
|
+
await renderAuthenticatedClient(
|
|
79
|
+
<AppThemeProvider>
|
|
80
|
+
<HeaderActions />
|
|
81
|
+
</AppThemeProvider>,
|
|
82
|
+
);
|
|
83
|
+
expect(
|
|
84
|
+
screen.queryByRole('button', { name: 'Theme' }),
|
|
85
|
+
).not.toBeInTheDocument();
|
|
86
|
+
await user.tab();
|
|
87
|
+
await user.keyboard('{Enter}');
|
|
88
|
+
const appearance = screen.getByRole('menuitem', { name: 'Theme' });
|
|
89
|
+
appearance.focus();
|
|
90
|
+
await user.keyboard('{ArrowRight}');
|
|
91
|
+
expect(screen.getAllByRole('menuitemradio')).toHaveLength(5);
|
|
92
|
+
await user.keyboard('{ArrowDown}{Enter}');
|
|
93
|
+
expect(document.documentElement).toHaveClass('dark');
|
|
94
|
+
expect(screen.getByRole('menuitemradio', { name: 'Dark' })).toHaveAttribute(
|
|
95
|
+
'aria-checked',
|
|
96
|
+
'true',
|
|
97
|
+
);
|
|
98
|
+
await user.keyboard('{Escape}');
|
|
99
|
+
expect(appearance).toHaveFocus();
|
|
100
|
+
expect(screen.getByRole('menuitem', { name: 'Sign out' })).toBeVisible();
|
|
101
|
+
await user.keyboard('{Escape}');
|
|
102
|
+
expect(
|
|
103
|
+
screen.getByRole('button', { name: 'Open account menu' }),
|
|
104
|
+
).toHaveFocus();
|
|
105
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ClientApplicationContext,
|
|
3
|
+
NavigationGuardProvider,
|
|
3
4
|
type ClientApplication,
|
|
4
5
|
createAppClientConfig,
|
|
5
6
|
} from '@nocobase/app-client';
|
|
@@ -70,7 +71,9 @@ async function setup(children: ReactNode) {
|
|
|
70
71
|
<I18nProvider runtime={runtime}>
|
|
71
72
|
<ClientApplicationContext.Provider value={app}>
|
|
72
73
|
<SidebarPreferenceProvider value={sidebarPreference}>
|
|
73
|
-
<MemoryRouter initialEntries={['/']}>
|
|
74
|
+
<MemoryRouter initialEntries={['/']}>
|
|
75
|
+
<NavigationGuardProvider>{children}</NavigationGuardProvider>
|
|
76
|
+
</MemoryRouter>
|
|
74
77
|
</SidebarPreferenceProvider>
|
|
75
78
|
</ClientApplicationContext.Provider>
|
|
76
79
|
</I18nProvider>,
|
|
@@ -85,13 +88,15 @@ describe('shell translations', () => {
|
|
|
85
88
|
expect(
|
|
86
89
|
screen.getByRole('navigation', { name: 'Breadcrumb' }),
|
|
87
90
|
).toHaveTextContent('Overview');
|
|
88
|
-
expect(screen.getByText('
|
|
91
|
+
expect(screen.getByText('Build reliable business systems')).toBeVisible();
|
|
92
|
+
expect(screen.queryByText('Default Template')).not.toBeInTheDocument();
|
|
89
93
|
await act(() => runtime.changeLanguage('zh-CN'));
|
|
90
94
|
expect(screen.getByRole('navigation', { name: '面包屑' })).toBeVisible();
|
|
91
|
-
expect(screen.getByText('
|
|
95
|
+
expect(screen.getByText('Build reliable business systems')).toBeVisible();
|
|
96
|
+
expect(screen.queryByText('Default Template')).not.toBeInTheDocument();
|
|
92
97
|
expect(
|
|
93
98
|
screen.getByRole('link', { name: 'NocoBase' }).parentElement,
|
|
94
|
-
).toHaveTextContent('NocoBase
|
|
99
|
+
).toHaveTextContent('AI + NocoBase');
|
|
95
100
|
expect(
|
|
96
101
|
screen.getByRole('button', { name: '展开或收起导航' }),
|
|
97
102
|
).toHaveAttribute('title', '展开或收起导航');
|
|
@@ -1,12 +1,55 @@
|
|
|
1
|
+
import packageMetadata from '../../package.json' with { type: 'json' };
|
|
1
2
|
import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
|
|
2
|
-
import {
|
|
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 { routes } = resolveAppClientContributions([
|
|
31
|
+
{
|
|
32
|
+
packageName: packageMetadata.name,
|
|
33
|
+
source: 'application',
|
|
34
|
+
routes: [shellRoutes, settingsRoutes],
|
|
35
|
+
},
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** The shell's route tree and permission check, as `AppLayout` provides them. */
|
|
39
|
+
function Shell({ children }: { children: ReactNode }) {
|
|
40
|
+
const { denied, loading } = useRouteNavigation(routes);
|
|
41
|
+
return (
|
|
42
|
+
<RouteTreeProvider routes={routes} access={{ denied, loading }}>
|
|
43
|
+
<output aria-label='Permission check'>
|
|
44
|
+
{loading ? 'Checking' : 'Ready'}
|
|
45
|
+
</output>
|
|
46
|
+
<RouteLeaveBoundary hidden={loading}>{children}</RouteLeaveBoundary>
|
|
47
|
+
</RouteTreeProvider>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
7
50
|
|
|
8
51
|
function mount(prefix: string, tab = 'roles', roles = true, locale = 'en-US') {
|
|
9
|
-
return renderAuthenticatedClient(tree(prefix)
|
|
52
|
+
return renderAuthenticatedClient(<Shell>{tree(prefix)}</Shell>, {
|
|
10
53
|
route: `${prefix}/${tab}`,
|
|
11
54
|
locale,
|
|
12
55
|
member: true,
|
|
@@ -41,8 +84,8 @@ function tree(prefix: string) {
|
|
|
41
84
|
</Routes>
|
|
42
85
|
);
|
|
43
86
|
}
|
|
44
|
-
it.each(['
|
|
45
|
-
'uses two URL-backed tabs under one heading
|
|
87
|
+
it.each(['/settings'])(
|
|
88
|
+
'uses two URL-backed tabs under one heading at the existing %s URLs and keeps keyboard focus',
|
|
46
89
|
async (prefix) => {
|
|
47
90
|
const user = userEvent.setup();
|
|
48
91
|
await mount(prefix);
|
|
@@ -85,23 +128,385 @@ it.each(['', '/settings'])(
|
|
|
85
128
|
);
|
|
86
129
|
},
|
|
87
130
|
);
|
|
88
|
-
it('
|
|
89
|
-
await mount('', 'default-access', false);
|
|
90
|
-
await screen.findByRole('
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
).toHaveAttribute('aria-selected', 'true');
|
|
131
|
+
it('shows a readable view without a single-tab bar and keeps direct navigation on it', async () => {
|
|
132
|
+
await mount('/settings', 'default-access', false);
|
|
133
|
+
await screen.findByRole('heading', { name: 'Permission management' });
|
|
134
|
+
// The module has one view this viewer may open, so it shows that view without a tab bar.
|
|
135
|
+
await waitFor(() =>
|
|
136
|
+
expect(screen.queryByRole('tablist')).not.toBeInTheDocument(),
|
|
137
|
+
);
|
|
138
|
+
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
|
|
97
139
|
expect(screen.getByRole('status', { name: 'Route' })).toHaveTextContent(
|
|
98
|
-
'/default-access',
|
|
140
|
+
'/settings/default-access',
|
|
99
141
|
);
|
|
100
142
|
});
|
|
101
143
|
it('uses the agreed Chinese menu and tab names', async () => {
|
|
102
|
-
await mount('', 'roles', true, 'zh-CN');
|
|
144
|
+
await mount('/settings', 'roles', true, 'zh-CN');
|
|
103
145
|
await screen.findByRole('tab', { name: '角色与权限' });
|
|
104
146
|
expect(screen.getByRole('heading', { name: '权限管理' })).toBeVisible();
|
|
105
147
|
expect(screen.getByRole('tab', { name: '角色与权限' })).toBeVisible();
|
|
106
148
|
expect(screen.getByRole('tab', { name: '默认数据范围' })).toBeVisible();
|
|
107
149
|
});
|
|
150
|
+
|
|
151
|
+
function Draft() {
|
|
152
|
+
const [value, setValue] = useState('');
|
|
153
|
+
const leave = useSettingsLeave(value !== '', false);
|
|
154
|
+
return (
|
|
155
|
+
<>
|
|
156
|
+
<input
|
|
157
|
+
aria-label='Draft'
|
|
158
|
+
value={value}
|
|
159
|
+
onChange={(event) => setValue(event.target.value)}
|
|
160
|
+
/>
|
|
161
|
+
<BodyPortal>
|
|
162
|
+
<div aria-label='Draft overlay' role='dialog'>
|
|
163
|
+
{value}
|
|
164
|
+
</div>
|
|
165
|
+
</BodyPortal>
|
|
166
|
+
{leave.dialog}
|
|
167
|
+
</>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function DraftPage() {
|
|
172
|
+
return (
|
|
173
|
+
<PermissionManagementPage tab='roles'>
|
|
174
|
+
<Draft />
|
|
175
|
+
</PermissionManagementPage>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
it.each([false, true])(
|
|
180
|
+
'preserves the mounted draft during permission rechecks (page guard: %s)',
|
|
181
|
+
async (guarded) => {
|
|
182
|
+
let hold = false;
|
|
183
|
+
let release!: () => void;
|
|
184
|
+
const pending = new Promise<void>((resolve) => {
|
|
185
|
+
release = resolve;
|
|
186
|
+
});
|
|
187
|
+
const view = await renderAuthenticatedClient(
|
|
188
|
+
<Shell>
|
|
189
|
+
{guarded ? (
|
|
190
|
+
<ClientRoute
|
|
191
|
+
route={{
|
|
192
|
+
id: 'draft',
|
|
193
|
+
name: 'draft',
|
|
194
|
+
path: '/draft',
|
|
195
|
+
auth: 'required',
|
|
196
|
+
authz: 'unrestricted',
|
|
197
|
+
packageName: packageMetadata.name,
|
|
198
|
+
source: 'application',
|
|
199
|
+
componentLoader: async () => ({ default: DraftPage }),
|
|
200
|
+
}}
|
|
201
|
+
/>
|
|
202
|
+
) : (
|
|
203
|
+
<DraftPage />
|
|
204
|
+
)}
|
|
205
|
+
</Shell>,
|
|
206
|
+
{
|
|
207
|
+
route: '/settings/roles',
|
|
208
|
+
beforeRequest: async (request) => {
|
|
209
|
+
if (
|
|
210
|
+
hold &&
|
|
211
|
+
new URL(request.url).pathname.endsWith('/authorization/permissions')
|
|
212
|
+
)
|
|
213
|
+
await pending;
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
await screen.findByRole('tab', { name: 'Roles and permissions' });
|
|
218
|
+
const draft = screen.getByRole('textbox', { name: 'Draft' });
|
|
219
|
+
await userEvent.type(draft, 'Keep this unsaved value');
|
|
220
|
+
try {
|
|
221
|
+
hold = true;
|
|
222
|
+
act(() =>
|
|
223
|
+
view.app.container.resolve(authorizationClientToken).invalidate(),
|
|
224
|
+
);
|
|
225
|
+
await waitFor(() =>
|
|
226
|
+
expect(
|
|
227
|
+
screen.getByRole('status', { name: 'Permission check' }),
|
|
228
|
+
).toHaveTextContent('Checking'),
|
|
229
|
+
);
|
|
230
|
+
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
|
|
231
|
+
expect(draft).toBeInTheDocument();
|
|
232
|
+
if (guarded) {
|
|
233
|
+
expect(draft).not.toBeVisible();
|
|
234
|
+
expect(
|
|
235
|
+
screen.queryByRole('dialog', { name: 'Draft overlay' }),
|
|
236
|
+
).not.toBeInTheDocument();
|
|
237
|
+
}
|
|
238
|
+
expect(draft).toHaveValue('Keep this unsaved value');
|
|
239
|
+
} finally {
|
|
240
|
+
hold = false;
|
|
241
|
+
release();
|
|
242
|
+
}
|
|
243
|
+
await screen.findByRole('tab', { name: 'Roles and permissions' });
|
|
244
|
+
expect(screen.getByRole('textbox', { name: 'Draft' })).toBe(draft);
|
|
245
|
+
expect(draft).toHaveValue('Keep this unsaved value');
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
it.each(['tab', 'breadcrumb'])(
|
|
250
|
+
'protects an inline module draft when leaving by %s',
|
|
251
|
+
async (destination) => {
|
|
252
|
+
const user = userEvent.setup();
|
|
253
|
+
await renderAuthenticatedClient(
|
|
254
|
+
<Shell>
|
|
255
|
+
<Breadcrumbs />
|
|
256
|
+
<Routes>
|
|
257
|
+
<Route path='/settings/roles' element={<DraftPage />} />
|
|
258
|
+
<Route
|
|
259
|
+
path='/settings/default-access'
|
|
260
|
+
element={<Content tab='default-access' />}
|
|
261
|
+
/>
|
|
262
|
+
<Route path='/settings' element={<h1>Settings destination</h1>} />
|
|
263
|
+
</Routes>
|
|
264
|
+
</Shell>,
|
|
265
|
+
{ route: '/settings/roles' },
|
|
266
|
+
);
|
|
267
|
+
const draft = await screen.findByRole('textbox', { name: 'Draft' });
|
|
268
|
+
await user.type(draft, 'Unsaved inline draft');
|
|
269
|
+
const target =
|
|
270
|
+
destination === 'tab'
|
|
271
|
+
? await screen.findByRole('tab', { name: 'Default data scopes' })
|
|
272
|
+
: screen.getByRole('link', { name: 'System settings' });
|
|
273
|
+
await user.click(target);
|
|
274
|
+
expect(await screen.findByRole('alertdialog')).toBeVisible();
|
|
275
|
+
await user.click(
|
|
276
|
+
screen.getByRole('button', { name: 'Cancel', exact: true }),
|
|
277
|
+
);
|
|
278
|
+
expect(draft).toHaveValue('Unsaved inline draft');
|
|
279
|
+
await user.click(target);
|
|
280
|
+
await user.click(
|
|
281
|
+
await screen.findByRole('button', { name: 'Confirm', exact: true }),
|
|
282
|
+
);
|
|
283
|
+
if (destination === 'tab')
|
|
284
|
+
await waitFor(() =>
|
|
285
|
+
expect(screen.getByRole('status', { name: 'Route' })).toHaveTextContent(
|
|
286
|
+
'/settings/default-access',
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
else
|
|
290
|
+
expect(
|
|
291
|
+
await screen.findByRole('heading', { name: 'Settings destination' }),
|
|
292
|
+
).toBeVisible();
|
|
293
|
+
expect(
|
|
294
|
+
screen.queryByRole('textbox', { name: 'Draft' }),
|
|
295
|
+
).not.toBeInTheDocument();
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
function HistoryControls() {
|
|
300
|
+
const navigate = useNavigate();
|
|
301
|
+
const location = useLocation();
|
|
302
|
+
return (
|
|
303
|
+
<>
|
|
304
|
+
<button onClick={() => navigate(-1)}>Browser back</button>
|
|
305
|
+
<output aria-label='Current URL'>{location.pathname}</output>
|
|
306
|
+
</>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
function NestedDraft() {
|
|
310
|
+
const request = useSettingsData<{ data: unknown[] }>('users');
|
|
311
|
+
return (
|
|
312
|
+
<>
|
|
313
|
+
<DraftPage />
|
|
314
|
+
<output aria-label='Loaded users'>
|
|
315
|
+
{request.data ? 'Ready' : 'Loading'}
|
|
316
|
+
</output>
|
|
317
|
+
</>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const nestedRoute = {
|
|
321
|
+
id: 'nested-draft',
|
|
322
|
+
name: 'Nested draft',
|
|
323
|
+
path: '/settings/roles/test/edit',
|
|
324
|
+
auth: 'required',
|
|
325
|
+
authz: {
|
|
326
|
+
resource: { type: 'settings', id: 'authorization.permission-sets' },
|
|
327
|
+
action: 'read',
|
|
328
|
+
},
|
|
329
|
+
packageName: packageMetadata.name,
|
|
330
|
+
source: 'application',
|
|
331
|
+
componentLoader: async () => ({ default: NestedDraft }),
|
|
332
|
+
} as const;
|
|
333
|
+
const parentRoute = {
|
|
334
|
+
...nestedRoute,
|
|
335
|
+
id: 'parent-draft',
|
|
336
|
+
path: '/settings/roles',
|
|
337
|
+
componentLoader: async () => ({ default: Outlet }),
|
|
338
|
+
};
|
|
339
|
+
function nestedTree() {
|
|
340
|
+
return (
|
|
341
|
+
<Shell>
|
|
342
|
+
<Breadcrumbs />
|
|
343
|
+
<HistoryControls />
|
|
344
|
+
<Routes>
|
|
345
|
+
<Route
|
|
346
|
+
path='/settings'
|
|
347
|
+
element={
|
|
348
|
+
<>
|
|
349
|
+
<h1>Settings destination</h1>
|
|
350
|
+
<Link to='/settings/roles/test/edit'>Edit role</Link>
|
|
351
|
+
</>
|
|
352
|
+
}
|
|
353
|
+
/>
|
|
354
|
+
<Route
|
|
355
|
+
path='/settings/roles'
|
|
356
|
+
element={<ClientRoute route={parentRoute} />}
|
|
357
|
+
>
|
|
358
|
+
<Route
|
|
359
|
+
path='test/edit'
|
|
360
|
+
element={<ClientRoute route={nestedRoute} />}
|
|
361
|
+
/>
|
|
362
|
+
</Route>
|
|
363
|
+
</Routes>
|
|
364
|
+
</Shell>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
function beforeUnload() {
|
|
368
|
+
const event = new Event('beforeunload', { cancelable: true });
|
|
369
|
+
window.dispatchEvent(event);
|
|
370
|
+
return event.defaultPrevented;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
it.each(['breadcrumb', 'back'])(
|
|
374
|
+
'protects a nested hidden draft during a pending permission request: %s',
|
|
375
|
+
async (destination) => {
|
|
376
|
+
const pending = Promise.withResolvers<void>();
|
|
377
|
+
let hold = false;
|
|
378
|
+
const view = await renderAuthenticatedClient(nestedTree(), {
|
|
379
|
+
route: '/settings',
|
|
380
|
+
beforeRequest: (request) =>
|
|
381
|
+
hold && request.url.endsWith('/authorization/permissions')
|
|
382
|
+
? pending.promise
|
|
383
|
+
: undefined,
|
|
384
|
+
});
|
|
385
|
+
const user = userEvent.setup();
|
|
386
|
+
await user.click(screen.getByRole('link', { name: 'Edit role' }));
|
|
387
|
+
const draft = await screen.findByRole('textbox', { name: 'Draft' });
|
|
388
|
+
await user.type(draft, 'Nested unsaved draft');
|
|
389
|
+
hold = true;
|
|
390
|
+
act(() =>
|
|
391
|
+
view.app.container.resolve(authorizationClientToken).invalidate(),
|
|
392
|
+
);
|
|
393
|
+
try {
|
|
394
|
+
await waitFor(() => expect(draft).not.toBeVisible());
|
|
395
|
+
expect(beforeUnload()).toBe(true);
|
|
396
|
+
const leave = async () => {
|
|
397
|
+
await user.click(
|
|
398
|
+
destination === 'breadcrumb'
|
|
399
|
+
? screen.getByRole('link', { name: 'System settings' })
|
|
400
|
+
: screen.getByRole('button', { name: 'Browser back' }),
|
|
401
|
+
);
|
|
402
|
+
expect(await screen.findByRole('alertdialog')).toBeVisible();
|
|
403
|
+
};
|
|
404
|
+
await leave();
|
|
405
|
+
await user.click(
|
|
406
|
+
screen.getByRole('button', { name: 'Cancel', exact: true }),
|
|
407
|
+
);
|
|
408
|
+
expect(
|
|
409
|
+
screen.getByRole('status', { name: 'Current URL' }),
|
|
410
|
+
).toHaveTextContent('/settings/roles/test/edit');
|
|
411
|
+
expect(draft).toHaveValue('Nested unsaved draft');
|
|
412
|
+
expect(beforeUnload()).toBe(true);
|
|
413
|
+
await leave();
|
|
414
|
+
await user.click(
|
|
415
|
+
screen.getByRole('button', { name: 'Confirm', exact: true }),
|
|
416
|
+
);
|
|
417
|
+
expect(
|
|
418
|
+
await screen.findByRole('heading', { name: 'Settings destination' }),
|
|
419
|
+
).toBeVisible();
|
|
420
|
+
expect(
|
|
421
|
+
screen.getByRole('status', { name: 'Current URL' }),
|
|
422
|
+
).toHaveTextContent(/^\/settings$/);
|
|
423
|
+
await waitFor(() => expect(beforeUnload()).toBe(false));
|
|
424
|
+
} finally {
|
|
425
|
+
hold = false;
|
|
426
|
+
pending.resolve();
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
it('restores the draft and restarts only the mounted data effect after permission revalidation', async () => {
|
|
432
|
+
const requests: string[] = [];
|
|
433
|
+
const pending = Promise.withResolvers<void>();
|
|
434
|
+
let hold = false;
|
|
435
|
+
const view = await renderAuthenticatedClient(nestedTree(), {
|
|
436
|
+
route: '/settings/roles/test/edit',
|
|
437
|
+
beforeRequest(request) {
|
|
438
|
+
requests.push(new URL(request.url).pathname);
|
|
439
|
+
if (hold && request.url.endsWith('/authorization/permissions'))
|
|
440
|
+
return pending.promise;
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
const draft = await screen.findByRole('textbox', { name: 'Draft' });
|
|
444
|
+
await screen.findByText('Ready', { selector: '[aria-label="Loaded users"]' });
|
|
445
|
+
await userEvent.type(draft, 'Retained after cancel');
|
|
446
|
+
const count = (suffix: string) =>
|
|
447
|
+
requests.filter((path) => path.endsWith(suffix)).length;
|
|
448
|
+
const permissions = count('/authorization/permissions');
|
|
449
|
+
const users = count('/users');
|
|
450
|
+
hold = true;
|
|
451
|
+
act(() => view.app.container.resolve(authorizationClientToken).invalidate());
|
|
452
|
+
try {
|
|
453
|
+
await waitFor(() => expect(draft).not.toBeVisible());
|
|
454
|
+
expect(count('/users')).toBe(users);
|
|
455
|
+
await userEvent.click(
|
|
456
|
+
screen.getByRole('link', { name: 'System settings' }),
|
|
457
|
+
);
|
|
458
|
+
await userEvent.click(
|
|
459
|
+
await screen.findByRole('button', { name: 'Cancel', exact: true }),
|
|
460
|
+
);
|
|
461
|
+
} finally {
|
|
462
|
+
hold = false;
|
|
463
|
+
pending.resolve();
|
|
464
|
+
}
|
|
465
|
+
await waitFor(() => expect(draft).toBeVisible());
|
|
466
|
+
expect(screen.getByRole('textbox', { name: 'Draft' })).toBe(draft);
|
|
467
|
+
expect(draft).toHaveValue('Retained after cancel');
|
|
468
|
+
await waitFor(() => expect(count('/users')).toBe(users + 1));
|
|
469
|
+
expect(count('/authorization/permissions')).toBe(permissions + 1);
|
|
470
|
+
expect(beforeUnload()).toBe(true);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('drops a hidden draft and its shell guard when the final permission result denies access', async () => {
|
|
474
|
+
const pending = Promise.withResolvers<void>();
|
|
475
|
+
let hold = false;
|
|
476
|
+
const view = await renderAuthenticatedClient(nestedTree(), {
|
|
477
|
+
route: '/settings/roles/test/edit',
|
|
478
|
+
member: true,
|
|
479
|
+
grants: [
|
|
480
|
+
{
|
|
481
|
+
resource: { type: 'settings', id: 'authorization.permission-sets' },
|
|
482
|
+
actions: [{ action: 'read' }],
|
|
483
|
+
},
|
|
484
|
+
],
|
|
485
|
+
beforeRequest: (request) =>
|
|
486
|
+
hold && request.url.endsWith('/authorization/permissions')
|
|
487
|
+
? pending.promise
|
|
488
|
+
: undefined,
|
|
489
|
+
});
|
|
490
|
+
const draft = await screen.findByRole('textbox', { name: 'Draft' });
|
|
491
|
+
await userEvent.type(draft, 'Must be discarded on denial');
|
|
492
|
+
hold = true;
|
|
493
|
+
act(() => view.app.container.resolve(authorizationClientToken).invalidate());
|
|
494
|
+
try {
|
|
495
|
+
await waitFor(() => expect(draft).not.toBeVisible());
|
|
496
|
+
expect(beforeUnload()).toBe(true);
|
|
497
|
+
await view.server.application.container
|
|
498
|
+
.resolve(authorizationToken)
|
|
499
|
+
.permissionSets.update('test-access', { key: 'test-access', grants: [] });
|
|
500
|
+
} finally {
|
|
501
|
+
hold = false;
|
|
502
|
+
pending.resolve();
|
|
503
|
+
}
|
|
504
|
+
expect(await screen.findByText('Access denied')).toBeVisible();
|
|
505
|
+
expect(draft).not.toBeInTheDocument();
|
|
506
|
+
await waitFor(() => expect(beforeUnload()).toBe(false));
|
|
507
|
+
await userEvent.click(screen.getByRole('link', { name: 'System settings' }));
|
|
508
|
+
expect(
|
|
509
|
+
await screen.findByRole('heading', { name: 'Settings destination' }),
|
|
510
|
+
).toBeVisible();
|
|
511
|
+
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
|
512
|
+
});
|