@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
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
3
|
+
import { RouteTreeProvider } from '../../client/routing/route-context.js';
|
|
4
|
+
import { renderSettingsClient } from '../helpers/settings-client.js';
|
|
2
5
|
import { answerApi } from '@nocobase/app-testing/client';
|
|
3
6
|
import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
|
|
4
7
|
import {
|
|
@@ -23,6 +26,28 @@ import { BackButton } from '../../client/components/back-button.js';
|
|
|
23
26
|
import enUS from '../../client/locales/en-US.js';
|
|
24
27
|
import zhCN from '../../client/locales/zh-CN.js';
|
|
25
28
|
const namespace = '@nocobase/continuity-test';
|
|
29
|
+
const routes: AppClientRegisteredRoute[] = [
|
|
30
|
+
'/settings/roles',
|
|
31
|
+
'/settings/processes/loan',
|
|
32
|
+
].map((path) => ({
|
|
33
|
+
id: path,
|
|
34
|
+
name: path,
|
|
35
|
+
path,
|
|
36
|
+
packageName: namespace,
|
|
37
|
+
source: 'application',
|
|
38
|
+
auth: 'required',
|
|
39
|
+
authz: 'skip',
|
|
40
|
+
componentLoader: async () => ({ default: () => null }),
|
|
41
|
+
}));
|
|
42
|
+
// Return targets are registered fixture pages, as they are in the running shell.
|
|
43
|
+
const renderWithApp = (
|
|
44
|
+
ui: ReactElement,
|
|
45
|
+
options: Parameters<typeof renderSettingsClient>[1] = {},
|
|
46
|
+
) =>
|
|
47
|
+
renderSettingsClient(
|
|
48
|
+
<RouteTreeProvider routes={routes}>{ui}</RouteTreeProvider>,
|
|
49
|
+
options,
|
|
50
|
+
);
|
|
26
51
|
const namespaces = {
|
|
27
52
|
[namespace]: {
|
|
28
53
|
'en-US': async () => ({ default: enUS }),
|
|
@@ -47,6 +72,7 @@ const options = {
|
|
|
47
72
|
],
|
|
48
73
|
},
|
|
49
74
|
};
|
|
75
|
+
const refreshing = enUS['status.refreshing'];
|
|
50
76
|
const row = {
|
|
51
77
|
id: 'alice',
|
|
52
78
|
name: 'Alice',
|
|
@@ -77,9 +103,8 @@ it.each(['fast', 'slow'] as const)(
|
|
|
77
103
|
await renderWithApp(
|
|
78
104
|
<Routes>
|
|
79
105
|
<Route path='/settings/users' element={<UsersPage />}>
|
|
80
|
-
<Route path=':userId' element={<UserDetail />}
|
|
81
|
-
|
|
82
|
-
</Route>
|
|
106
|
+
<Route path=':userId' element={<UserDetail />} />
|
|
107
|
+
<Route path=':userId/edit' element={<UserForm />} />
|
|
83
108
|
</Route>
|
|
84
109
|
</Routes>,
|
|
85
110
|
{
|
|
@@ -110,17 +135,17 @@ it.each(['fast', 'slow'] as const)(
|
|
|
110
135
|
vi.useFakeTimers();
|
|
111
136
|
try {
|
|
112
137
|
await act(() => vi.advanceTimersByTimeAsync(1000));
|
|
113
|
-
expect(screen.
|
|
138
|
+
expect(screen.queryByRole('status', { name: refreshing })).toBeNull();
|
|
114
139
|
await act(async () => {
|
|
115
140
|
fireEvent.click(
|
|
116
141
|
screen.getByRole('button', { name: 'Cancel', exact: true }),
|
|
117
142
|
);
|
|
118
143
|
});
|
|
119
144
|
expect(screen.getByRole('link', { name: 'Alice' })).toBeVisible();
|
|
120
|
-
expect(screen.
|
|
145
|
+
expect(screen.queryByRole('status', { name: refreshing })).toBeNull();
|
|
121
146
|
if (speed === 'slow') {
|
|
122
147
|
await act(() => vi.advanceTimersByTimeAsync(1000));
|
|
123
|
-
expect(screen.
|
|
148
|
+
expect(screen.getByRole('status', { name: refreshing })).toBeVisible();
|
|
124
149
|
expect(screen.getByRole('link', { name: 'Alice' })).toBeVisible();
|
|
125
150
|
}
|
|
126
151
|
await act(async () => {
|
|
@@ -131,7 +156,7 @@ it.each(['fast', 'slow'] as const)(
|
|
|
131
156
|
expect(
|
|
132
157
|
screen.getByRole('link', { name: 'Alice refreshed' }),
|
|
133
158
|
).toBeVisible();
|
|
134
|
-
expect(screen.
|
|
159
|
+
expect(screen.queryByRole('status', { name: refreshing })).toBeNull();
|
|
135
160
|
} finally {
|
|
136
161
|
vi.useRealTimers();
|
|
137
162
|
}
|
|
@@ -320,9 +345,8 @@ it.each(['/settings/users', '/users'])(
|
|
|
320
345
|
<>
|
|
321
346
|
<Routes>
|
|
322
347
|
<Route path={base} element={<UsersPage />}>
|
|
323
|
-
<Route path=':userId' element={<UserDetail />}
|
|
324
|
-
|
|
325
|
-
</Route>
|
|
348
|
+
<Route path=':userId' element={<UserDetail />} />
|
|
349
|
+
<Route path=':userId/edit' element={<UserForm />} />
|
|
326
350
|
</Route>
|
|
327
351
|
</Routes>
|
|
328
352
|
<Location />
|
|
@@ -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 { screen, waitFor, within } from '@testing-library/react';
|
|
5
5
|
import userEvent from '@testing-library/user-event';
|
|
@@ -13,6 +13,8 @@ import shellRoutes from '../../client/pages/settings/shell-routes.js';
|
|
|
13
13
|
import { RouteTreeProvider } from '../../client/routing/route-context.js';
|
|
14
14
|
import { useRouteNavigation } from '../../client/routing/route-navigation.js';
|
|
15
15
|
|
|
16
|
+
const renderAuthenticatedClient = createAuthenticatedClientRenderer();
|
|
17
|
+
|
|
16
18
|
const { routes } = resolveAppClientContributions([
|
|
17
19
|
{
|
|
18
20
|
packageName: packageMetadata.name,
|
|
@@ -104,7 +106,7 @@ it('links the System settings breadcrumb back to the home from a module view', a
|
|
|
104
106
|
await mount({}, '/settings/roles');
|
|
105
107
|
const trail = await screen.findByRole('navigation', { name: 'Breadcrumb' });
|
|
106
108
|
expect(
|
|
107
|
-
within(trail).
|
|
109
|
+
await within(trail).findByRole('link', { name: 'System settings' }),
|
|
108
110
|
).toHaveAttribute('href', '/main/settings');
|
|
109
111
|
expect(within(trail).getByText('Roles and permissions')).toBeVisible();
|
|
110
112
|
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { answerApi } from '@nocobase/app-testing/client';
|
|
2
|
+
import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
|
|
3
|
+
import {
|
|
4
|
+
AuthorizationClient,
|
|
5
|
+
authorizationClientToken,
|
|
6
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
7
|
+
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
|
8
|
+
import { Route, Routes, useNavigate } from 'react-router';
|
|
9
|
+
import { expect, it, vi } from 'vitest';
|
|
10
|
+
import { renderSettingsClient } from '../helpers/settings-client.js';
|
|
11
|
+
import UsersPage from '../../client/pages/settings/users.js';
|
|
12
|
+
import UserForm from '../../client/pages/settings/user-form.js';
|
|
13
|
+
import RolesPage from '../../client/pages/settings/roles.js';
|
|
14
|
+
import enUS from '../../client/locales/en-US.js';
|
|
15
|
+
|
|
16
|
+
const namespace = '@nocobase/idle-list-test';
|
|
17
|
+
const services = (app: ClientApplication) =>
|
|
18
|
+
app.container.instance(
|
|
19
|
+
authorizationClientToken,
|
|
20
|
+
new AuthorizationClient(app.container.resolve(apiClientToken)),
|
|
21
|
+
);
|
|
22
|
+
function ReturnToRoles() {
|
|
23
|
+
const navigate = useNavigate();
|
|
24
|
+
return (
|
|
25
|
+
<button onClick={() => void navigate('/settings/roles')}>
|
|
26
|
+
Return to roles
|
|
27
|
+
</button>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
it.each(['users', 'roles'] as const)(
|
|
32
|
+
'keeps a cold %s child route silent and starts real loading on return',
|
|
33
|
+
async (kind) => {
|
|
34
|
+
const listPath =
|
|
35
|
+
kind === 'users' ? 'users' : 'authorization/permissionSets';
|
|
36
|
+
let finish!: (value: unknown) => void;
|
|
37
|
+
const response = new Promise((resolve) => {
|
|
38
|
+
finish = resolve;
|
|
39
|
+
});
|
|
40
|
+
const calls = vi.fn(({ path }: { path: string }) => {
|
|
41
|
+
if (path === 'authorization/permissions')
|
|
42
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
43
|
+
if (path === 'users/options') return { data: { roleScopes: [] } };
|
|
44
|
+
if (path === listPath) return response;
|
|
45
|
+
return new Response(null, { status: 404 });
|
|
46
|
+
});
|
|
47
|
+
await renderSettingsClient(
|
|
48
|
+
<Routes>
|
|
49
|
+
<Route path='/settings/users' element={<UsersPage />}>
|
|
50
|
+
<Route path='create' element={<UserForm />} />
|
|
51
|
+
</Route>
|
|
52
|
+
<Route path='/settings/roles' element={<RolesPage />}>
|
|
53
|
+
<Route path='create' element={<ReturnToRoles />} />
|
|
54
|
+
</Route>
|
|
55
|
+
</Routes>,
|
|
56
|
+
{
|
|
57
|
+
route: `/settings/${kind}/create`,
|
|
58
|
+
namespace,
|
|
59
|
+
namespaces: {
|
|
60
|
+
[namespace]: { 'en-US': async () => ({ default: enUS }) },
|
|
61
|
+
},
|
|
62
|
+
services,
|
|
63
|
+
fetch: answerApi(calls),
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
// The real Users form is interactive; Roles uses the real list with a nested route.
|
|
67
|
+
const close = await screen.findByRole('button', {
|
|
68
|
+
name: kind === 'users' ? 'Cancel' : 'Return to roles',
|
|
69
|
+
exact: true,
|
|
70
|
+
});
|
|
71
|
+
vi.useFakeTimers();
|
|
72
|
+
try {
|
|
73
|
+
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
|
74
|
+
expect(
|
|
75
|
+
calls.mock.calls.filter(([call]) => call.path === listPath),
|
|
76
|
+
).toHaveLength(0);
|
|
77
|
+
expect(document.querySelector('[data-slot="loading"]')).toBeNull();
|
|
78
|
+
expect(screen.queryByText(enUS.pendingFeedback.longWait)).toBeNull();
|
|
79
|
+
expect(screen.queryByText(enUS.listFeedback.empty)).toBeNull();
|
|
80
|
+
await act(async () => {
|
|
81
|
+
fireEvent.click(close);
|
|
82
|
+
});
|
|
83
|
+
expect(
|
|
84
|
+
calls.mock.calls.filter(([call]) => call.path === listPath),
|
|
85
|
+
).toHaveLength(1);
|
|
86
|
+
await act(() => vi.advanceTimersByTimeAsync(299));
|
|
87
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
88
|
+
await act(() => vi.advanceTimersByTimeAsync(1));
|
|
89
|
+
expect(screen.getAllByRole('status')).toHaveLength(1);
|
|
90
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
91
|
+
enUS.listFeedback.loading,
|
|
92
|
+
);
|
|
93
|
+
await act(async () => {
|
|
94
|
+
finish(
|
|
95
|
+
kind === 'users'
|
|
96
|
+
? {
|
|
97
|
+
data: [
|
|
98
|
+
{
|
|
99
|
+
id: 'alice',
|
|
100
|
+
name: 'Alice',
|
|
101
|
+
username: 'alice',
|
|
102
|
+
email: 'alice@example.test',
|
|
103
|
+
roleScopes: {},
|
|
104
|
+
disabledAt: null,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
meta: { total: 1 },
|
|
108
|
+
}
|
|
109
|
+
: { data: [{ key: 'team', title: 'Team', grants: [] }] },
|
|
110
|
+
);
|
|
111
|
+
await response;
|
|
112
|
+
});
|
|
113
|
+
} finally {
|
|
114
|
+
finish({ data: [], meta: { total: 0 } });
|
|
115
|
+
vi.useRealTimers();
|
|
116
|
+
}
|
|
117
|
+
await waitFor(() =>
|
|
118
|
+
expect(
|
|
119
|
+
screen.getByText(kind === 'users' ? 'Alice' : 'Team', { exact: true }),
|
|
120
|
+
).toBeVisible(),
|
|
121
|
+
);
|
|
122
|
+
expect(screen.queryByRole('status')).toBeNull();
|
|
123
|
+
},
|
|
124
|
+
);
|
|
@@ -228,3 +228,23 @@ describe('System settings navigation', () => {
|
|
|
228
228
|
).toEqual(['home', 'settingsHome']);
|
|
229
229
|
});
|
|
230
230
|
});
|
|
231
|
+
|
|
232
|
+
it('keeps database browsing accessible by route without adding a System settings card', () => {
|
|
233
|
+
const routes = resolveAppClientContributions([
|
|
234
|
+
{
|
|
235
|
+
packageName: app,
|
|
236
|
+
source: 'application',
|
|
237
|
+
routes: createApplicationRoutes({}),
|
|
238
|
+
},
|
|
239
|
+
]).routes;
|
|
240
|
+
const page = find(routes, 'databaseExplorer');
|
|
241
|
+
expect(page.path).toBe('/settings/database-explorer');
|
|
242
|
+
expect(page.auth).toBe('required');
|
|
243
|
+
expect(page.authz).toEqual({
|
|
244
|
+
resource: { type: 'page', id: 'database-explorer' },
|
|
245
|
+
action: 'access',
|
|
246
|
+
});
|
|
247
|
+
expect(page.navigation).toBeUndefined();
|
|
248
|
+
expect(catalog(routes)).toEqual([]);
|
|
249
|
+
expect(catalog(routes, new Set([routeKey(page)]))).toEqual([]);
|
|
250
|
+
});
|
|
@@ -51,9 +51,8 @@ function Pages({ base }: { readonly base: string }) {
|
|
|
51
51
|
<Routes>
|
|
52
52
|
<Route path={base} element={<UsersPage />}>
|
|
53
53
|
<Route path='create' element={<UserForm />} />
|
|
54
|
-
<Route path=':userId' element={<UserDetail />}
|
|
55
|
-
|
|
56
|
-
</Route>
|
|
54
|
+
<Route path=':userId' element={<UserDetail />} />
|
|
55
|
+
<Route path=':userId/edit' element={<UserForm />} />
|
|
57
56
|
</Route>
|
|
58
57
|
<Route path='*' element={<h1>Fallback home</h1>} />
|
|
59
58
|
</Routes>
|
|
@@ -451,7 +450,7 @@ it('blocks closing and duplicate submission until the unified save completes', a
|
|
|
451
450
|
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
|
|
452
451
|
await waitFor(() => expect(name).toBeDisabled());
|
|
453
452
|
expect(
|
|
454
|
-
screen.
|
|
453
|
+
await screen.findByRole('button', { name: enUS.settings.saving }),
|
|
455
454
|
).toBeDisabled();
|
|
456
455
|
expect(
|
|
457
456
|
screen.getByRole('button', { name: 'Cancel', exact: true }),
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Dialog footer regression</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/tests/fixtures/dialog-footer.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { createMemoryRouter, Link, Outlet, RouterProvider } from 'react-router';
|
|
4
|
+
import {
|
|
5
|
+
createTestI18nRuntime,
|
|
6
|
+
TestI18nProvider,
|
|
7
|
+
} from '@nocobase/i18n/testing';
|
|
8
|
+
import { Button } from '../../client/components/ui/button';
|
|
9
|
+
import { Input } from '../../client/components/ui/input';
|
|
10
|
+
import { Spinner } from '../../client/components/ui/spinner';
|
|
11
|
+
import {
|
|
12
|
+
Dialog,
|
|
13
|
+
DialogClose,
|
|
14
|
+
DialogContent,
|
|
15
|
+
DialogDescription,
|
|
16
|
+
DialogFooter,
|
|
17
|
+
DialogHeader,
|
|
18
|
+
DialogTitle,
|
|
19
|
+
} from '../../client/components/ui/dialog';
|
|
20
|
+
import {
|
|
21
|
+
AlertDialog,
|
|
22
|
+
AlertDialogAction,
|
|
23
|
+
AlertDialogCancel,
|
|
24
|
+
AlertDialogContent,
|
|
25
|
+
AlertDialogDescription,
|
|
26
|
+
AlertDialogFooter,
|
|
27
|
+
AlertDialogHeader,
|
|
28
|
+
AlertDialogTitle,
|
|
29
|
+
} from '../../client/components/ui/alert-dialog';
|
|
30
|
+
import { RouteDialog } from '../../client/components/route-dialog';
|
|
31
|
+
import { RouteDrawer } from '../../client/components/route-drawer';
|
|
32
|
+
import enUS from '../../client/locales/en-US';
|
|
33
|
+
import '../../client/styles.css';
|
|
34
|
+
|
|
35
|
+
// Exercise installed primitives, portals, links and route footers with the real
|
|
36
|
+
// application stylesheet. No backend or authentication is needed for this fixture.
|
|
37
|
+
const params = new URLSearchParams(location.search);
|
|
38
|
+
const kind = params.get('kind') ?? 'dialog';
|
|
39
|
+
const chinese = params.has('chinese');
|
|
40
|
+
const long = params.has('long');
|
|
41
|
+
const pending = params.has('pending');
|
|
42
|
+
const horizontal = params.has('horizontal');
|
|
43
|
+
document.documentElement.dataset.theme = params.get('preset') ?? 'default';
|
|
44
|
+
document.documentElement.classList.toggle('dark', params.has('dark'));
|
|
45
|
+
const runtime = await createTestI18nRuntime({
|
|
46
|
+
application: { namespace: '@nocobase/app-template-default', resources: enUS },
|
|
47
|
+
});
|
|
48
|
+
const cancel = chinese ? '取消并返回实例列表' : 'Cancel';
|
|
49
|
+
const submit = chinese
|
|
50
|
+
? '确认创建这个具有较长中文名称的应用实例并继续配置'
|
|
51
|
+
: long
|
|
52
|
+
? 'Create this application instance and continue configuring its workspace'
|
|
53
|
+
: 'Create instance';
|
|
54
|
+
const title = chinese ? '创建应用实例' : 'Create application instance';
|
|
55
|
+
const description = chinese
|
|
56
|
+
? '输入实例名称,然后创建实例。'
|
|
57
|
+
: 'Enter a name to create an application instance.';
|
|
58
|
+
|
|
59
|
+
function Editor() {
|
|
60
|
+
return (
|
|
61
|
+
<form
|
|
62
|
+
id='instance-form'
|
|
63
|
+
onSubmit={(event) => {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
void router.navigate('/instances?created=1');
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<label htmlFor='instance-name'>
|
|
69
|
+
{chinese ? '实例名称' : 'Instance name'}
|
|
70
|
+
</label>
|
|
71
|
+
<Input id='instance-name' required defaultValue='Customer portal' />
|
|
72
|
+
{long && !kind.startsWith('alert') && (
|
|
73
|
+
<div className='h-[900px]' aria-hidden='true' />
|
|
74
|
+
)}
|
|
75
|
+
</form>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Actions() {
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
{kind === 'dialog-close' ? (
|
|
83
|
+
<DialogClose
|
|
84
|
+
disabled={pending}
|
|
85
|
+
data-testid='cancel'
|
|
86
|
+
render={<Button variant='outline' />}
|
|
87
|
+
>
|
|
88
|
+
{cancel}
|
|
89
|
+
</DialogClose>
|
|
90
|
+
) : (
|
|
91
|
+
<Button
|
|
92
|
+
variant='outline'
|
|
93
|
+
nativeButton={false}
|
|
94
|
+
disabled={pending}
|
|
95
|
+
data-testid='cancel'
|
|
96
|
+
render={<Link to='/instances?filter=recent' />}
|
|
97
|
+
>
|
|
98
|
+
{cancel}
|
|
99
|
+
</Button>
|
|
100
|
+
)}
|
|
101
|
+
<Button
|
|
102
|
+
type='submit'
|
|
103
|
+
form='instance-form'
|
|
104
|
+
disabled={pending}
|
|
105
|
+
aria-busy={pending}
|
|
106
|
+
data-testid='submit'
|
|
107
|
+
>
|
|
108
|
+
{pending && <Spinner data-icon='inline-start' />}
|
|
109
|
+
{pending ? 'Creating instance…' : submit}
|
|
110
|
+
</Button>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default function Overlay() {
|
|
116
|
+
const [open, setOpen] = useState(true);
|
|
117
|
+
if (kind === 'route' || kind === 'drawer') {
|
|
118
|
+
const Component = kind === 'drawer' ? RouteDrawer : RouteDialog;
|
|
119
|
+
return (
|
|
120
|
+
<Component
|
|
121
|
+
title={title}
|
|
122
|
+
description={description}
|
|
123
|
+
footer={<Actions />}
|
|
124
|
+
beforeClose={() => !pending}
|
|
125
|
+
>
|
|
126
|
+
<Editor />
|
|
127
|
+
</Component>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (kind === 'alert' || kind === 'alert-small') {
|
|
131
|
+
return (
|
|
132
|
+
<AlertDialog
|
|
133
|
+
open={open}
|
|
134
|
+
onOpenChange={(value) => {
|
|
135
|
+
if (!pending) setOpen(value);
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
<AlertDialogContent size={kind === 'alert-small' ? 'sm' : 'default'}>
|
|
139
|
+
<AlertDialogHeader>
|
|
140
|
+
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
141
|
+
<AlertDialogDescription>{description}</AlertDialogDescription>
|
|
142
|
+
</AlertDialogHeader>
|
|
143
|
+
<Editor />
|
|
144
|
+
<AlertDialogFooter
|
|
145
|
+
className={horizontal ? 'flex-row items-center' : undefined}
|
|
146
|
+
>
|
|
147
|
+
<AlertDialogCancel disabled={pending} data-testid='cancel'>
|
|
148
|
+
{cancel}
|
|
149
|
+
</AlertDialogCancel>
|
|
150
|
+
<AlertDialogAction
|
|
151
|
+
type='submit'
|
|
152
|
+
form='instance-form'
|
|
153
|
+
disabled={pending}
|
|
154
|
+
aria-busy={pending}
|
|
155
|
+
data-testid='submit'
|
|
156
|
+
>
|
|
157
|
+
{pending && <Spinner data-icon='inline-start' />}
|
|
158
|
+
{pending ? 'Creating instance…' : submit}
|
|
159
|
+
</AlertDialogAction>
|
|
160
|
+
</AlertDialogFooter>
|
|
161
|
+
</AlertDialogContent>
|
|
162
|
+
</AlertDialog>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return (
|
|
166
|
+
<Dialog
|
|
167
|
+
open={open}
|
|
168
|
+
onOpenChange={(value) => {
|
|
169
|
+
if (!pending) setOpen(value);
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
<DialogContent className='flex flex-col overflow-hidden'>
|
|
173
|
+
<DialogHeader>
|
|
174
|
+
<DialogTitle>{title}</DialogTitle>
|
|
175
|
+
<DialogDescription>{description}</DialogDescription>
|
|
176
|
+
</DialogHeader>
|
|
177
|
+
<div className='min-h-0 overflow-y-auto'>
|
|
178
|
+
<Editor />
|
|
179
|
+
</div>
|
|
180
|
+
<DialogFooter
|
|
181
|
+
className={horizontal ? 'flex-row items-center' : undefined}
|
|
182
|
+
>
|
|
183
|
+
<Actions />
|
|
184
|
+
</DialogFooter>
|
|
185
|
+
</DialogContent>
|
|
186
|
+
</Dialog>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const router = createMemoryRouter(
|
|
191
|
+
[
|
|
192
|
+
{
|
|
193
|
+
path: '/instances',
|
|
194
|
+
element: (
|
|
195
|
+
<main className='p-6'>
|
|
196
|
+
<h1>Application instances</h1>
|
|
197
|
+
<p data-testid='location'>Instance list</p>
|
|
198
|
+
<Outlet />
|
|
199
|
+
</main>
|
|
200
|
+
),
|
|
201
|
+
children: [{ path: 'new', element: <Overlay /> }],
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
{ initialEntries: ['/instances/new?filter=recent'] },
|
|
205
|
+
);
|
|
206
|
+
router.subscribe((state) => {
|
|
207
|
+
const output = document.querySelector('[data-testid=location]');
|
|
208
|
+
if (output)
|
|
209
|
+
output.textContent = state.location.pathname + state.location.search;
|
|
210
|
+
});
|
|
211
|
+
createRoot(document.getElementById('root')!).render(
|
|
212
|
+
<TestI18nProvider runtime={runtime}>
|
|
213
|
+
<RouterProvider router={router} />
|
|
214
|
+
</TestI18nProvider>,
|
|
215
|
+
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactElement } from 'react';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { CookieJar } from 'jsdom';
|
|
3
|
-
import { onTestFinished, vi } from 'vitest';
|
|
4
|
+
import { afterAll, beforeAll, onTestFinished, vi } from 'vitest';
|
|
4
5
|
import { act, waitFor } from '@testing-library/react';
|
|
5
6
|
import { createTestApp, type TestApp } from '@nocobase/app-testing/server';
|
|
6
7
|
import { renderWithApp } from '@nocobase/app-testing/client';
|
|
@@ -36,27 +37,18 @@ export async function renderAuthenticatedClient(
|
|
|
36
37
|
response: Response,
|
|
37
38
|
) => void | Promise<void>;
|
|
38
39
|
} = {},
|
|
40
|
+
sharedServer?: TestApp,
|
|
39
41
|
) {
|
|
40
|
-
const server = await
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
logging: { enabled: false },
|
|
45
|
-
app: { publicOrigin: 'http://localhost' },
|
|
46
|
-
auth: {
|
|
47
|
-
secret: 'test-auth-secret-at-least-32-characters',
|
|
48
|
-
emailAndPassword: { disableSignUp: false },
|
|
49
|
-
advanced: { disableOriginCheck: false, disableCSRFCheck: false },
|
|
50
|
-
},
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
|
-
onTestFinished(() => server.close());
|
|
42
|
+
const server = sharedServer ?? (await createClientTestServer());
|
|
43
|
+
if (!sharedServer) onTestFinished(() => server.close());
|
|
44
|
+
const identity = sharedServer ? randomUUID() : '';
|
|
45
|
+
const permissionSetKey = identity ? `test-access-${identity}` : 'test-access';
|
|
54
46
|
const member = {
|
|
55
47
|
name: 'Test member',
|
|
56
|
-
email: 'member@example.test',
|
|
48
|
+
email: identity ? `member-${identity}@example.test` : 'member@example.test',
|
|
57
49
|
password: 'test-member-password',
|
|
58
50
|
};
|
|
59
|
-
if (options.member) {
|
|
51
|
+
if (options.member || sharedServer) {
|
|
60
52
|
const response = await server.request('/auth/sign-up/email', {
|
|
61
53
|
method: 'POST',
|
|
62
54
|
headers: {
|
|
@@ -68,22 +60,84 @@ export async function renderAuthenticatedClient(
|
|
|
68
60
|
if (!response.ok)
|
|
69
61
|
throw new Error(`Member registration failed: ${await response.text()}`);
|
|
70
62
|
}
|
|
63
|
+
const authz = server.application.container.resolve(authorizationToken);
|
|
71
64
|
const session = await signIn(
|
|
72
65
|
server,
|
|
73
|
-
options.member ? member : DEFAULT_ADMIN_CREDENTIALS,
|
|
66
|
+
options.member || sharedServer ? member : DEFAULT_ADMIN_CREDENTIALS,
|
|
74
67
|
);
|
|
68
|
+
if (sharedServer && !options.member) {
|
|
69
|
+
await authz.permissionSets.assign({
|
|
70
|
+
id: randomUUID(),
|
|
71
|
+
permissionSet: 'root',
|
|
72
|
+
subject: { type: 'user', id: session.user.id },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
75
|
if (options.grants) {
|
|
76
|
-
const authz = server.application.container.resolve(authorizationToken);
|
|
77
76
|
await authz.permissionSets.create({
|
|
78
|
-
key:
|
|
77
|
+
key: permissionSetKey,
|
|
79
78
|
grants: options.grants,
|
|
80
79
|
});
|
|
81
80
|
await authz.permissionSets.assign({
|
|
82
|
-
id: 'test-assignment',
|
|
83
|
-
permissionSet:
|
|
81
|
+
id: identity ? `test-assignment-${identity}` : 'test-assignment',
|
|
82
|
+
permissionSet: permissionSetKey,
|
|
84
83
|
subject: { type: 'user', id: session.user.id },
|
|
85
84
|
});
|
|
86
85
|
}
|
|
86
|
+
return renderClient(server, session, permissionSetKey, ui, options);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createClientTestServer() {
|
|
90
|
+
return createTestApp({
|
|
91
|
+
createServer: createStandaloneServer,
|
|
92
|
+
// Local preview origins must not change test cookie security or trusted origins.
|
|
93
|
+
server: {
|
|
94
|
+
env: {
|
|
95
|
+
APP_PUBLIC_ORIGIN: 'http://localhost',
|
|
96
|
+
BETTER_AUTH_TRUSTED_ORIGINS: 'http://localhost',
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
connections: ['main'],
|
|
100
|
+
config: {
|
|
101
|
+
logging: { enabled: false },
|
|
102
|
+
app: { publicOrigin: 'http://localhost' },
|
|
103
|
+
auth: {
|
|
104
|
+
secret: 'test-auth-secret-at-least-32-characters',
|
|
105
|
+
emailAndPassword: { disableSignUp: false },
|
|
106
|
+
advanced: { disableOriginCheck: false, disableCSRFCheck: false },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reuse installation within a file whose tests only change their own identity's state.
|
|
114
|
+
* Each render gets a new user, session, grants, cookies and client. Tests that mutate
|
|
115
|
+
* shared application configuration or business data use renderAuthenticatedClient instead.
|
|
116
|
+
*/
|
|
117
|
+
export function createAuthenticatedClientRenderer() {
|
|
118
|
+
let server: TestApp | undefined;
|
|
119
|
+
beforeAll(async () => {
|
|
120
|
+
server = await createClientTestServer();
|
|
121
|
+
});
|
|
122
|
+
afterAll(async () => {
|
|
123
|
+
await server?.close();
|
|
124
|
+
});
|
|
125
|
+
return (
|
|
126
|
+
ui: ReactElement,
|
|
127
|
+
options: Parameters<typeof renderAuthenticatedClient>[1] = {},
|
|
128
|
+
) => {
|
|
129
|
+
if (!server) throw new Error('The test application has not started.');
|
|
130
|
+
return renderAuthenticatedClient(ui, options, server);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function renderClient(
|
|
135
|
+
server: TestApp,
|
|
136
|
+
session: TestSession,
|
|
137
|
+
permissionSetKey: string,
|
|
138
|
+
ui: ReactElement,
|
|
139
|
+
options: NonNullable<Parameters<typeof renderAuthenticatedClient>[1]>,
|
|
140
|
+
) {
|
|
87
141
|
await options.prepare?.(server, session);
|
|
88
142
|
const jar = new CookieJar();
|
|
89
143
|
const root = `http://localhost${server.publicBasePath.replace(/\/$/u, '')}`;
|
|
@@ -135,6 +189,7 @@ export async function renderAuthenticatedClient(
|
|
|
135
189
|
...view,
|
|
136
190
|
server,
|
|
137
191
|
session,
|
|
192
|
+
permissionSetKey,
|
|
138
193
|
identity: () => identity!,
|
|
139
194
|
async signInAs(credentials: SignInCredentials) {
|
|
140
195
|
const next = await signIn(server, credentials);
|