@nocobase/app-template-default 1.0.0-beta.88 → 1.0.0-beta.90
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 +12 -2
- package/AGENTS.md +1 -1
- package/LICENSE.txt +129 -0
- 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/lib/navigation-transform.ts +29 -1
- package/client/locales/system/en-US.ts +23 -0
- package/client/locales/system/zh-CN.ts +23 -0
- package/client/pages/home/index.tsx +66 -51
- package/client/pages/settings/database-explorer/en-US.ts +33 -0
- package/client/pages/settings/database-explorer/index.tsx +358 -0
- package/client/pages/settings/database-explorer/routes.ts +14 -0
- package/client/pages/settings/database-explorer/zh-CN.ts +30 -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/plugins.ts +8 -1
- package/client/routes.ts +2 -1
- 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/config.example.yml +17 -11
- package/index.html +8 -11
- package/package.json +27 -27
- package/server/config/auth.ts +1 -1
- package/server/config/index.ts +3 -0
- package/server/config/jobs.ts +16 -8
- package/server/config/queue.ts +14 -13
- package/server/config/realtime.ts +4 -0
- 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/client-routes.test.ts +5 -3
- package/tests/client/database-explorer-integration.test.tsx +30 -0
- package/tests/client/database-explorer.test.tsx +152 -0
- package/tests/client/foundation-integration.test.ts +42 -29
- package/tests/client/home.test.tsx +26 -85
- package/tests/client/layout-i18n.test.tsx +83 -2
- 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/settings-navigation.test.ts +20 -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 +77 -22
- package/tests/playwright/dialog-footer.test.ts +227 -0
- package/tests/project/bootstrap-loading.test.ts +60 -0
- package/tests/server/app-server.test.ts +18 -12
- package/tests/server/auth-email-config.test.ts +21 -0
- package/tests/server/backends-config.test.ts +118 -0
- package/tests/server/concurrency.test.ts +1 -1
- package/tests/server/config.test.ts +22 -14
- package/tests/server/lifecycle.test.ts +2 -2
- package/tests/server/realtime-config.test.ts +114 -0
- package/client/pages/home/capabilities.ts +0 -62
- package/client/pages/home/prompt-dialog.tsx +0 -122
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppClientRegisteredRoute,
|
|
3
|
+
AppClientRouteComponentModule,
|
|
4
|
+
} from '@nocobase/app-client/plugins';
|
|
5
|
+
import { apiClientToken } from '@nocobase/app-client';
|
|
6
|
+
import {
|
|
7
|
+
AuthorizationClient,
|
|
8
|
+
authorizationClientToken,
|
|
9
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
10
|
+
import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
|
|
11
|
+
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
|
12
|
+
import userEvent from '@testing-library/user-event';
|
|
13
|
+
import {
|
|
14
|
+
TestI18nProvider,
|
|
15
|
+
createTestI18nRuntime,
|
|
16
|
+
} from '@nocobase/i18n/testing';
|
|
17
|
+
import { Activity, useState, useLayoutEffect, type ReactNode } from 'react';
|
|
18
|
+
import {
|
|
19
|
+
createMemoryRouter,
|
|
20
|
+
Link,
|
|
21
|
+
Outlet,
|
|
22
|
+
RouterProvider,
|
|
23
|
+
Routes,
|
|
24
|
+
useLocation,
|
|
25
|
+
} from 'react-router';
|
|
26
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
27
|
+
|
|
28
|
+
import { RouteChildPage } from '../../client/components/route-child-page';
|
|
29
|
+
import { RouteDialog } from '../../client/components/route-dialog';
|
|
30
|
+
import { RouteDrawer } from '../../client/components/route-drawer';
|
|
31
|
+
import { RouteOverlayHost } from '../../client/components/route-overlay';
|
|
32
|
+
import { renderRouteTree } from '../../client/routing/route-tree.tsx';
|
|
33
|
+
import packageMetadata from '../../package.json' with { type: 'json' };
|
|
34
|
+
import enUS from '../../client/locales/en-US.js';
|
|
35
|
+
|
|
36
|
+
const options = {
|
|
37
|
+
namespace: packageMetadata.name,
|
|
38
|
+
namespaces: { [packageMetadata.name]: enUS },
|
|
39
|
+
route: '/items',
|
|
40
|
+
services(app: import('@nocobase/app-client').ClientApplication) {
|
|
41
|
+
app.container.singleton(
|
|
42
|
+
authorizationClientToken,
|
|
43
|
+
(resolver) => new AuthorizationClient(resolver.resolve(apiClientToken)),
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Loading a lazy route can take longer than Testing Library's one-second default on a loaded CI runner.
|
|
49
|
+
const LOAD = { timeout: 10_000 };
|
|
50
|
+
|
|
51
|
+
function deferred<T>() {
|
|
52
|
+
let resolve!: (value: T) => void;
|
|
53
|
+
let reject!: (error: unknown) => void;
|
|
54
|
+
const promise = new Promise<T>((done, fail) => {
|
|
55
|
+
resolve = done;
|
|
56
|
+
reject = fail;
|
|
57
|
+
});
|
|
58
|
+
return { promise, resolve, reject };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Location() {
|
|
62
|
+
return <output data-testid='location'>{useLocation().pathname}</output>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ListPage() {
|
|
66
|
+
const [draft, setDraft] = useState('');
|
|
67
|
+
return (
|
|
68
|
+
<main>
|
|
69
|
+
<h1>Items</h1>
|
|
70
|
+
<label>
|
|
71
|
+
Parent note
|
|
72
|
+
<input
|
|
73
|
+
value={draft}
|
|
74
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
75
|
+
/>
|
|
76
|
+
</label>
|
|
77
|
+
<Link to='create'>Open create</Link>
|
|
78
|
+
<Link to='pages'>Open child page</Link>
|
|
79
|
+
<Link to='legacy'>Open legacy</Link>
|
|
80
|
+
<Outlet />
|
|
81
|
+
</main>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function CreateForm() {
|
|
86
|
+
return (
|
|
87
|
+
<RouteDialog
|
|
88
|
+
title='Create item'
|
|
89
|
+
footer={<button type='button'>Save item</button>}
|
|
90
|
+
>
|
|
91
|
+
<p>Item form</p>
|
|
92
|
+
<Link to='details'>Open details</Link>
|
|
93
|
+
<Outlet />
|
|
94
|
+
</RouteDialog>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function page(
|
|
99
|
+
name: string,
|
|
100
|
+
path: string,
|
|
101
|
+
load: () => Promise<AppClientRouteComponentModule>,
|
|
102
|
+
extra: Partial<AppClientRegisteredRoute> = {},
|
|
103
|
+
): AppClientRegisteredRoute {
|
|
104
|
+
return {
|
|
105
|
+
id: `test:${name}`,
|
|
106
|
+
name,
|
|
107
|
+
path,
|
|
108
|
+
auth: 'required',
|
|
109
|
+
authz: 'skip',
|
|
110
|
+
packageName: packageMetadata.name,
|
|
111
|
+
source: 'application',
|
|
112
|
+
componentLoader: load,
|
|
113
|
+
...extra,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function itemRoutes(
|
|
118
|
+
children: readonly AppClientRegisteredRoute[],
|
|
119
|
+
): readonly AppClientRegisteredRoute[] {
|
|
120
|
+
return [
|
|
121
|
+
page('items', '/items', async () => ({ default: ListPage }), {
|
|
122
|
+
children,
|
|
123
|
+
}),
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function renderItems(
|
|
128
|
+
children: readonly AppClientRegisteredRoute[],
|
|
129
|
+
fetch?: (request: Request) => Promise<Response>,
|
|
130
|
+
) {
|
|
131
|
+
const user = userEvent.setup();
|
|
132
|
+
await renderWithApp(
|
|
133
|
+
<>
|
|
134
|
+
<Routes>{renderRouteTree(itemRoutes(children))}</Routes>
|
|
135
|
+
<Location />
|
|
136
|
+
</>,
|
|
137
|
+
{ ...options, ...(fetch ? { fetch } : {}) },
|
|
138
|
+
);
|
|
139
|
+
await screen.findByRole('heading', { name: 'Items' }, LOAD);
|
|
140
|
+
return user;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe('route presentations', () => {
|
|
144
|
+
it('opens a declared dialog before its module arrives and fills that one panel', async () => {
|
|
145
|
+
const module = deferred<AppClientRouteComponentModule>();
|
|
146
|
+
const user = await renderItems([
|
|
147
|
+
page('create', '/items/create', () => module.promise, {
|
|
148
|
+
presentation: { kind: 'dialog', title: 'settings.newUser' },
|
|
149
|
+
}),
|
|
150
|
+
]);
|
|
151
|
+
await user.type(screen.getByLabelText('Parent note'), 'kept');
|
|
152
|
+
await user.click(screen.getByRole('link', { name: 'Open create' }));
|
|
153
|
+
|
|
154
|
+
const dialog = await screen.findByRole('dialog', { name: 'New user' });
|
|
155
|
+
expect(dialog.querySelector('[data-slot="loading"]')).not.toBeNull();
|
|
156
|
+
// The wait belongs to the dialog, so nothing is added to the page under it.
|
|
157
|
+
expect(
|
|
158
|
+
screen
|
|
159
|
+
.getByRole('main', { hidden: true })
|
|
160
|
+
.querySelector('[data-slot="loading"]'),
|
|
161
|
+
).toBeNull();
|
|
162
|
+
expect(
|
|
163
|
+
within(dialog).queryByRole('button', { name: 'Save item' }),
|
|
164
|
+
).toBeNull();
|
|
165
|
+
|
|
166
|
+
await act(async () => module.resolve({ default: CreateForm }));
|
|
167
|
+
await screen.findByText('Item form', {}, LOAD);
|
|
168
|
+
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
|
169
|
+
const ready = screen.getByRole('dialog', { name: 'Create item' });
|
|
170
|
+
expect(
|
|
171
|
+
within(ready).getByRole('button', { name: 'Save item' }),
|
|
172
|
+
).toBeVisible();
|
|
173
|
+
expect(ready.querySelector('[data-slot="loading"]')).toBeNull();
|
|
174
|
+
|
|
175
|
+
await user.keyboard('{Escape}');
|
|
176
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
177
|
+
expect(screen.getByTestId('location')).toHaveTextContent(/^\/items$/);
|
|
178
|
+
expect(screen.getByLabelText('Parent note')).toHaveValue('kept');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('closes a declared drawer that is still loading back to its parent', async () => {
|
|
182
|
+
const user = await renderItems([
|
|
183
|
+
page('create', '/items/create', () => new Promise(() => undefined), {
|
|
184
|
+
presentation: { kind: 'drawer', title: 'settings.newUser' },
|
|
185
|
+
}),
|
|
186
|
+
]);
|
|
187
|
+
await user.click(screen.getByRole('link', { name: 'Open create' }));
|
|
188
|
+
const drawer = await screen.findByRole('dialog', { name: 'New user' });
|
|
189
|
+
expect(drawer).toHaveAttribute('data-presentation', 'drawer');
|
|
190
|
+
await user.keyboard('{Escape}');
|
|
191
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
192
|
+
expect(screen.getByTestId('location')).toHaveTextContent(/^\/items$/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('allows closing a cold permission check without a mounted form', async () => {
|
|
196
|
+
const permissions = deferred<unknown>();
|
|
197
|
+
const user = await renderItems(
|
|
198
|
+
[
|
|
199
|
+
page('create', '/items/create', async () => ({ default: CreateForm }), {
|
|
200
|
+
authz: { resource: { type: 'user', id: '*' }, action: 'create' },
|
|
201
|
+
presentation: { kind: 'dialog', title: 'settings.newUser' },
|
|
202
|
+
}),
|
|
203
|
+
],
|
|
204
|
+
answerApi(({ path }) =>
|
|
205
|
+
path === 'authorization/permissions' ? permissions.promise : null,
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
try {
|
|
209
|
+
await user.click(screen.getByRole('link', { name: 'Open create' }));
|
|
210
|
+
await screen.findByRole('dialog', { name: 'New user' });
|
|
211
|
+
await user.keyboard('{Escape}');
|
|
212
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
213
|
+
expect(screen.getByTestId('location')).toHaveTextContent(/^\/items$/);
|
|
214
|
+
} finally {
|
|
215
|
+
await act(async () =>
|
|
216
|
+
permissions.resolve({ data: { unrestricted: true, permissions: [] } }),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('keeps a slow authorization check and its denial inside the declared dialog', async () => {
|
|
222
|
+
const permissions = deferred<unknown>();
|
|
223
|
+
const load = vi.fn(async () => ({ default: CreateForm }));
|
|
224
|
+
const user = await renderItems(
|
|
225
|
+
[
|
|
226
|
+
page('create', '/items/create', load, {
|
|
227
|
+
authz: { resource: { type: 'user', id: '*' }, action: 'create' },
|
|
228
|
+
presentation: { kind: 'dialog', title: 'settings.newUser' },
|
|
229
|
+
}),
|
|
230
|
+
],
|
|
231
|
+
answerApi(({ path }) =>
|
|
232
|
+
path === 'authorization/permissions' ? permissions.promise : null,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
await user.click(screen.getByRole('link', { name: 'Open create' }));
|
|
236
|
+
|
|
237
|
+
const dialog = await screen.findByRole('dialog', { name: 'New user' });
|
|
238
|
+
expect(dialog.querySelector('[data-slot="loading"]')).not.toBeNull();
|
|
239
|
+
expect(load).not.toHaveBeenCalled();
|
|
240
|
+
|
|
241
|
+
await act(async () =>
|
|
242
|
+
permissions.resolve({ data: { unrestricted: false, permissions: [] } }),
|
|
243
|
+
);
|
|
244
|
+
expect(
|
|
245
|
+
await within(dialog).findByText('Access denied', {}, LOAD),
|
|
246
|
+
).toBeVisible();
|
|
247
|
+
expect(load).not.toHaveBeenCalled();
|
|
248
|
+
expect(screen.queryByText('Item form')).toBeNull();
|
|
249
|
+
expect(
|
|
250
|
+
screen
|
|
251
|
+
.getByRole('main', { hidden: true })
|
|
252
|
+
.querySelector('[data-slot="loading"]'),
|
|
253
|
+
).toBeNull();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('retries a failed module inside its dialog without remounting the parent page', async () => {
|
|
257
|
+
const load = vi
|
|
258
|
+
.fn<() => Promise<AppClientRouteComponentModule>>()
|
|
259
|
+
.mockRejectedValueOnce(new Error('Chunk unavailable'))
|
|
260
|
+
.mockResolvedValue({ default: CreateForm });
|
|
261
|
+
const user = await renderItems([
|
|
262
|
+
page('create', '/items/create', load, {
|
|
263
|
+
presentation: { kind: 'dialog', title: 'settings.newUser' },
|
|
264
|
+
}),
|
|
265
|
+
]);
|
|
266
|
+
await user.type(screen.getByLabelText('Parent note'), 'draft');
|
|
267
|
+
await user.click(screen.getByRole('link', { name: 'Open create' }));
|
|
268
|
+
|
|
269
|
+
const dialog = await screen.findByRole('dialog', { name: 'New user' });
|
|
270
|
+
expect(
|
|
271
|
+
await within(dialog).findByText('Unable to load page', {}, LOAD),
|
|
272
|
+
).toBeVisible();
|
|
273
|
+
await user.click(within(dialog).getByRole('button', { name: 'Retry' }));
|
|
274
|
+
|
|
275
|
+
await screen.findByText('Item form', {}, LOAD);
|
|
276
|
+
expect(load).toHaveBeenCalledTimes(2);
|
|
277
|
+
expect(screen.queryByRole('button', { name: 'Reload page' })).toBeNull();
|
|
278
|
+
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
|
279
|
+
expect(
|
|
280
|
+
screen.getByLabelText('Parent note', { selector: 'input' }),
|
|
281
|
+
).toHaveValue('draft');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('offers a full reload only after retrying the module failed again', async () => {
|
|
285
|
+
const load = vi.fn<() => Promise<AppClientRouteComponentModule>>(() =>
|
|
286
|
+
Promise.reject(new Error('Chunk unavailable')),
|
|
287
|
+
);
|
|
288
|
+
const user = await renderItems([
|
|
289
|
+
page('create', '/items/create', load, {
|
|
290
|
+
presentation: { kind: 'dialog', title: 'settings.newUser' },
|
|
291
|
+
}),
|
|
292
|
+
]);
|
|
293
|
+
await user.click(screen.getByRole('link', { name: 'Open create' }));
|
|
294
|
+
const dialog = await screen.findByRole('dialog', { name: 'New user' });
|
|
295
|
+
await within(dialog).findByText('Unable to load page', {}, LOAD);
|
|
296
|
+
expect(
|
|
297
|
+
within(dialog).queryByRole('button', { name: 'Reload page' }),
|
|
298
|
+
).toBeNull();
|
|
299
|
+
|
|
300
|
+
await user.click(within(dialog).getByRole('button', { name: 'Retry' }));
|
|
301
|
+
expect(
|
|
302
|
+
await within(dialog).findByRole('button', { name: 'Reload page' }, LOAD),
|
|
303
|
+
).toBeVisible();
|
|
304
|
+
expect(load).toHaveBeenCalledTimes(2);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('keeps a route without a presentation in charge of its own overlay, nested in a hosted one', async () => {
|
|
308
|
+
const user = await renderItems([
|
|
309
|
+
page('create', '/items/create', async () => ({ default: CreateForm }), {
|
|
310
|
+
presentation: { kind: 'dialog', title: 'settings.newUser' },
|
|
311
|
+
children: [
|
|
312
|
+
page('details', '/items/create/details', async () => ({
|
|
313
|
+
default: () => (
|
|
314
|
+
<RouteDrawer title='Item details'>
|
|
315
|
+
<p>Details body</p>
|
|
316
|
+
</RouteDrawer>
|
|
317
|
+
),
|
|
318
|
+
})),
|
|
319
|
+
],
|
|
320
|
+
}),
|
|
321
|
+
page('legacy', '/items/legacy', async () => ({
|
|
322
|
+
default: () => (
|
|
323
|
+
<RouteDialog title='Legacy dialog'>
|
|
324
|
+
<p>Legacy body</p>
|
|
325
|
+
</RouteDialog>
|
|
326
|
+
),
|
|
327
|
+
})),
|
|
328
|
+
]);
|
|
329
|
+
|
|
330
|
+
await user.click(screen.getByRole('link', { name: 'Open legacy' }));
|
|
331
|
+
await screen.findByText('Legacy body', {}, LOAD);
|
|
332
|
+
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
|
333
|
+
await user.keyboard('{Escape}');
|
|
334
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
335
|
+
|
|
336
|
+
await user.click(screen.getByRole('link', { name: 'Open create' }));
|
|
337
|
+
await screen.findByText('Item form', {}, LOAD);
|
|
338
|
+
await user.click(screen.getByRole('link', { name: 'Open details' }));
|
|
339
|
+
await screen.findByText('Details body', {}, LOAD);
|
|
340
|
+
// The nested route builds its own drawer and leaves the hosted dialog's title alone.
|
|
341
|
+
const panels = [
|
|
342
|
+
...document.querySelectorAll('[data-slot="route-overlay-content"]'),
|
|
343
|
+
];
|
|
344
|
+
expect(
|
|
345
|
+
panels.map(
|
|
346
|
+
(panel) =>
|
|
347
|
+
panel.querySelector('[data-slot="route-overlay-header"] h2')
|
|
348
|
+
?.textContent,
|
|
349
|
+
),
|
|
350
|
+
).toEqual(['Create item', 'Item details']);
|
|
351
|
+
expect(panels[1]).toHaveAttribute('data-presentation', 'drawer');
|
|
352
|
+
|
|
353
|
+
await user.keyboard('{Escape}');
|
|
354
|
+
await waitFor(() =>
|
|
355
|
+
expect(screen.queryByRole('dialog', { name: 'Item details' })).toBeNull(),
|
|
356
|
+
);
|
|
357
|
+
expect(screen.getByRole('dialog', { name: 'Create item' })).toBeVisible();
|
|
358
|
+
expect(screen.getByTestId('location')).toHaveTextContent(
|
|
359
|
+
/^\/items\/create$/,
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('covers the parent with a declared child page while its module loads', async () => {
|
|
364
|
+
const module = deferred<AppClientRouteComponentModule>();
|
|
365
|
+
const user = await renderItems([
|
|
366
|
+
page('pages', '/items/pages', () => module.promise, {
|
|
367
|
+
presentation: { kind: 'child-page' },
|
|
368
|
+
}),
|
|
369
|
+
]);
|
|
370
|
+
await user.click(screen.getByRole('link', { name: 'Open child page' }));
|
|
371
|
+
|
|
372
|
+
await waitFor(() =>
|
|
373
|
+
expect(
|
|
374
|
+
document.querySelectorAll('[data-slot="route-child-page"]'),
|
|
375
|
+
).toHaveLength(1),
|
|
376
|
+
);
|
|
377
|
+
const layer = document.querySelector('[data-slot="route-child-page"]')!;
|
|
378
|
+
expect(layer.querySelector('[data-slot="loading"]')).not.toBeNull();
|
|
379
|
+
|
|
380
|
+
await act(async () =>
|
|
381
|
+
module.resolve({
|
|
382
|
+
default: () => (
|
|
383
|
+
<RouteChildPage className='bg-muted'>
|
|
384
|
+
<h2>Child content</h2>
|
|
385
|
+
</RouteChildPage>
|
|
386
|
+
),
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
await screen.findByRole('heading', { name: 'Child content' }, LOAD);
|
|
390
|
+
expect(
|
|
391
|
+
document.querySelectorAll('[data-slot="route-child-page"]'),
|
|
392
|
+
).toHaveLength(1);
|
|
393
|
+
expect(layer.querySelector('[data-slot="loading"]')).toBeNull();
|
|
394
|
+
expect(layer).toContainElement(
|
|
395
|
+
screen.getByRole('heading', { name: 'Child content' }),
|
|
396
|
+
);
|
|
397
|
+
// The page's own layer classes move onto the layer the application opened.
|
|
398
|
+
expect(layer).toHaveClass('bg-muted');
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const runtime = await createTestI18nRuntime({
|
|
403
|
+
application: {
|
|
404
|
+
namespace: '@nocobase/app-template-default',
|
|
405
|
+
resources: enUS,
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
describe('route overlay host', () => {
|
|
410
|
+
function Host({
|
|
411
|
+
concealed,
|
|
412
|
+
beforeClose,
|
|
413
|
+
}: {
|
|
414
|
+
readonly concealed: boolean;
|
|
415
|
+
readonly beforeClose: () => boolean | Promise<boolean>;
|
|
416
|
+
}): ReactNode {
|
|
417
|
+
return (
|
|
418
|
+
<RouteOverlayHost title='New item' ready concealed={concealed}>
|
|
419
|
+
<Activity mode={concealed ? 'hidden' : 'visible'}>
|
|
420
|
+
<RouteDialog
|
|
421
|
+
title='Secret record'
|
|
422
|
+
footer={<button type='button'>Save item</button>}
|
|
423
|
+
beforeClose={beforeClose}
|
|
424
|
+
>
|
|
425
|
+
<p>Form body</p>
|
|
426
|
+
</RouteDialog>
|
|
427
|
+
</Activity>
|
|
428
|
+
</RouteOverlayHost>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
it('conceals a nested portal below a hidden parent DOM element and restores the same panel', async () => {
|
|
433
|
+
let conceal!: (value: boolean) => void;
|
|
434
|
+
function Harness() {
|
|
435
|
+
const [hidden, setHidden] = useState(false);
|
|
436
|
+
useLayoutEffect(() => {
|
|
437
|
+
conceal = setHidden;
|
|
438
|
+
}, []);
|
|
439
|
+
return (
|
|
440
|
+
<Activity mode={hidden ? 'hidden' : 'visible'}>
|
|
441
|
+
<main>
|
|
442
|
+
<Host concealed={false} beforeClose={() => false} />
|
|
443
|
+
</main>
|
|
444
|
+
</Activity>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
const router = createMemoryRouter(
|
|
448
|
+
[{ path: '/items/create', element: <Harness /> }],
|
|
449
|
+
{ initialEntries: ['/items/create'] },
|
|
450
|
+
);
|
|
451
|
+
render(
|
|
452
|
+
<TestI18nProvider runtime={runtime}>
|
|
453
|
+
<RouterProvider router={router} />
|
|
454
|
+
</TestI18nProvider>,
|
|
455
|
+
);
|
|
456
|
+
const panel = await screen.findByRole('dialog', { name: 'Secret record' });
|
|
457
|
+
await act(async () => conceal(true));
|
|
458
|
+
expect(panel).not.toBeVisible();
|
|
459
|
+
expect(panel.closest('[data-slot="dialog-portal"]')).toHaveStyle({
|
|
460
|
+
display: 'none',
|
|
461
|
+
});
|
|
462
|
+
await act(async () => conceal(false));
|
|
463
|
+
expect(screen.getByRole('dialog', { name: 'Secret record' })).toBe(panel);
|
|
464
|
+
expect(screen.getByRole('button', { name: 'Save item' })).toBeVisible();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('never resumes an async close that crossed concealment, even when it settles after recovery', async () => {
|
|
468
|
+
const decision = deferred<boolean>();
|
|
469
|
+
let conceal!: (value: boolean) => void;
|
|
470
|
+
const beforeClose = vi.fn(() => decision.promise);
|
|
471
|
+
function Harness() {
|
|
472
|
+
const [concealed, setConcealed] = useState(false);
|
|
473
|
+
useLayoutEffect(() => {
|
|
474
|
+
conceal = setConcealed;
|
|
475
|
+
}, []);
|
|
476
|
+
return <Host concealed={concealed} beforeClose={beforeClose} />;
|
|
477
|
+
}
|
|
478
|
+
const router = createMemoryRouter(
|
|
479
|
+
[
|
|
480
|
+
{
|
|
481
|
+
path: '/items',
|
|
482
|
+
children: [{ path: 'create', element: <Harness /> }],
|
|
483
|
+
},
|
|
484
|
+
],
|
|
485
|
+
{ initialEntries: ['/items/create'] },
|
|
486
|
+
);
|
|
487
|
+
render(
|
|
488
|
+
<TestI18nProvider runtime={runtime}>
|
|
489
|
+
<RouterProvider router={router} />
|
|
490
|
+
</TestI18nProvider>,
|
|
491
|
+
);
|
|
492
|
+
const user = userEvent.setup();
|
|
493
|
+
await screen.findByRole('dialog', { name: 'Secret record' });
|
|
494
|
+
await user.keyboard('{Escape}');
|
|
495
|
+
await waitFor(() => expect(beforeClose).toHaveBeenCalledTimes(1));
|
|
496
|
+
await act(async () => conceal(true));
|
|
497
|
+
await act(async () => conceal(false));
|
|
498
|
+
await act(async () => decision.resolve(true));
|
|
499
|
+
expect(screen.getByRole('dialog', { name: 'Secret record' })).toBeVisible();
|
|
500
|
+
expect(router.state.location.pathname).toBe('/items/create');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('hides protected slots and refuses closing without calling the disconnected page guard', async () => {
|
|
504
|
+
const user = userEvent.setup();
|
|
505
|
+
const beforeClose = vi.fn(() => false);
|
|
506
|
+
function Harness() {
|
|
507
|
+
const [concealed, setConcealed] = useState(false);
|
|
508
|
+
return (
|
|
509
|
+
<>
|
|
510
|
+
<button type='button' onClick={() => setConcealed(!concealed)}>
|
|
511
|
+
Toggle recheck
|
|
512
|
+
</button>
|
|
513
|
+
<Host concealed={concealed} beforeClose={beforeClose} />
|
|
514
|
+
</>
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
const toggle = () =>
|
|
518
|
+
act(() =>
|
|
519
|
+
screen
|
|
520
|
+
.getByRole('button', { name: 'Toggle recheck', hidden: true })
|
|
521
|
+
.click(),
|
|
522
|
+
);
|
|
523
|
+
const router = createMemoryRouter(
|
|
524
|
+
[
|
|
525
|
+
{
|
|
526
|
+
path: '/items',
|
|
527
|
+
children: [{ path: 'create', element: <Harness /> }],
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
{ initialEntries: ['/items/create'] },
|
|
531
|
+
);
|
|
532
|
+
render(
|
|
533
|
+
<TestI18nProvider runtime={runtime}>
|
|
534
|
+
<RouterProvider router={router} />
|
|
535
|
+
</TestI18nProvider>,
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
const dialog = await screen.findByRole('dialog', { name: 'Secret record' });
|
|
539
|
+
expect(
|
|
540
|
+
within(dialog).getByRole('button', { name: 'Save item' }),
|
|
541
|
+
).toBeVisible();
|
|
542
|
+
|
|
543
|
+
toggle();
|
|
544
|
+
await screen.findByRole('dialog', { name: 'New item' });
|
|
545
|
+
expect(screen.queryByText('Secret record')).toBeNull();
|
|
546
|
+
expect(screen.queryByRole('button', { name: 'Save item' })).toBeNull();
|
|
547
|
+
|
|
548
|
+
await user.keyboard('{Escape}');
|
|
549
|
+
expect(beforeClose).not.toHaveBeenCalled();
|
|
550
|
+
expect(router.state.location.pathname).toBe('/items/create');
|
|
551
|
+
|
|
552
|
+
toggle();
|
|
553
|
+
expect(
|
|
554
|
+
await screen.findByRole('dialog', { name: 'Secret record' }),
|
|
555
|
+
).toBeVisible();
|
|
556
|
+
});
|
|
557
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ClientApplicationContext,
|
|
3
|
+
NavigationGuardProvider,
|
|
3
4
|
realtimeClientToken,
|
|
4
5
|
type ClientApplication,
|
|
5
6
|
} from '@nocobase/app-client';
|
|
@@ -130,23 +131,25 @@ async function setup() {
|
|
|
130
131
|
<Capture />
|
|
131
132
|
<AuthorizationProvider>
|
|
132
133
|
<MemoryRouter>
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
134
|
+
<NavigationGuardProvider>
|
|
135
|
+
<AppRouter
|
|
136
|
+
clientRoutes={[
|
|
137
|
+
route,
|
|
138
|
+
{
|
|
139
|
+
id: 'login',
|
|
140
|
+
name: 'login',
|
|
141
|
+
path: '/login',
|
|
142
|
+
auth: 'guest',
|
|
143
|
+
authz: 'skip',
|
|
144
|
+
source: 'application',
|
|
145
|
+
packageName: 'test',
|
|
146
|
+
componentLoader: async () => ({
|
|
147
|
+
default: () => <p>Login route</p>,
|
|
148
|
+
}),
|
|
149
|
+
},
|
|
150
|
+
]}
|
|
151
|
+
/>
|
|
152
|
+
</NavigationGuardProvider>
|
|
150
153
|
</MemoryRouter>
|
|
151
154
|
</AuthorizationProvider>
|
|
152
155
|
</AuthenticationProvider>
|