@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
|
@@ -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 />
|
|
@@ -0,0 +1,112 @@
|
|
|
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 { screen, waitFor, within } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import type { ReactNode } from 'react';
|
|
7
|
+
import { Route, Routes, useLocation } from 'react-router';
|
|
8
|
+
import { expect, it } from 'vitest';
|
|
9
|
+
import { Breadcrumbs } from '../../client/components/breadcrumbs.js';
|
|
10
|
+
import SettingsHomePage from '../../client/pages/settings/settings-home.js';
|
|
11
|
+
import settingsRoutes from '../../client/pages/settings/routes.js';
|
|
12
|
+
import shellRoutes from '../../client/pages/settings/shell-routes.js';
|
|
13
|
+
import { RouteTreeProvider } from '../../client/routing/route-context.js';
|
|
14
|
+
import { useRouteNavigation } from '../../client/routing/route-navigation.js';
|
|
15
|
+
|
|
16
|
+
const renderAuthenticatedClient = createAuthenticatedClientRenderer();
|
|
17
|
+
|
|
18
|
+
const { routes } = resolveAppClientContributions([
|
|
19
|
+
{
|
|
20
|
+
packageName: packageMetadata.name,
|
|
21
|
+
source: 'application',
|
|
22
|
+
routes: [shellRoutes, settingsRoutes],
|
|
23
|
+
},
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function Shell({ children }: { children: ReactNode }) {
|
|
27
|
+
const { denied, loading } = useRouteNavigation(routes);
|
|
28
|
+
return (
|
|
29
|
+
<RouteTreeProvider routes={routes} access={{ denied, loading }}>
|
|
30
|
+
<Breadcrumbs denied={denied} />
|
|
31
|
+
{children}
|
|
32
|
+
</RouteTreeProvider>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Where() {
|
|
37
|
+
return <output aria-label='Route'>{useLocation().pathname}</output>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mount(
|
|
41
|
+
options: Parameters<typeof renderAuthenticatedClient>[1] = {},
|
|
42
|
+
route = '/settings',
|
|
43
|
+
) {
|
|
44
|
+
return renderAuthenticatedClient(
|
|
45
|
+
<Shell>
|
|
46
|
+
<Routes>
|
|
47
|
+
<Route path='/settings' element={<SettingsHomePage />} />
|
|
48
|
+
<Route path='*' element={<Where />} />
|
|
49
|
+
</Routes>
|
|
50
|
+
</Shell>,
|
|
51
|
+
{ route, ...options },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
it('groups the modules an administrator may open into categories of cards that open by keyboard', async () => {
|
|
56
|
+
const user = userEvent.setup();
|
|
57
|
+
await mount();
|
|
58
|
+
const account = await screen.findByRole('region', {
|
|
59
|
+
name: 'Accounts and permissions',
|
|
60
|
+
});
|
|
61
|
+
const cards = within(account).getAllByRole('link');
|
|
62
|
+
expect(cards.map((card) => card.textContent)).toEqual([
|
|
63
|
+
'UsersManage accounts, access and active sessions.',
|
|
64
|
+
'Permission managementSet what each role can open and do, and the data it can see.',
|
|
65
|
+
]);
|
|
66
|
+
expect(cards[0]).toHaveAttribute('href', '/main/settings/users');
|
|
67
|
+
expect(cards[1]).toHaveAttribute('href', '/main/settings/roles');
|
|
68
|
+
// Categories without a module this viewer may open are not shown.
|
|
69
|
+
expect(screen.getAllByRole('region')).toHaveLength(1);
|
|
70
|
+
cards[1].focus();
|
|
71
|
+
await user.keyboard('{Enter}');
|
|
72
|
+
await waitFor(() =>
|
|
73
|
+
expect(screen.getByRole('status', { name: 'Route' })).toHaveTextContent(
|
|
74
|
+
'/settings/roles',
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('shows a restricted viewer only what they may open, opening a tabbed module on its readable view', async () => {
|
|
80
|
+
await mount({
|
|
81
|
+
member: true,
|
|
82
|
+
grants: [
|
|
83
|
+
{
|
|
84
|
+
resource: { type: 'settings', id: 'authorization.default-access' },
|
|
85
|
+
actions: [{ action: 'read' }],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
const account = await screen.findByRole('region', {
|
|
90
|
+
name: 'Accounts and permissions',
|
|
91
|
+
});
|
|
92
|
+
// Categories without a module this viewer may open are not shown.
|
|
93
|
+
expect(screen.getAllByRole('region')).toHaveLength(1);
|
|
94
|
+
const cards = within(account).getAllByRole('link');
|
|
95
|
+
expect(cards).toHaveLength(1);
|
|
96
|
+
expect(cards[0]).toHaveAttribute('href', '/main/settings/default-access');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('explains when there is nothing to manage', async () => {
|
|
100
|
+
await mount({ member: true, locale: 'zh-CN' });
|
|
101
|
+
expect(await screen.findByText('暂时没有你可以管理的设置。')).toBeVisible();
|
|
102
|
+
expect(screen.getByRole('heading', { name: '系统设置' })).toBeVisible();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('links the System settings breadcrumb back to the home from a module view', async () => {
|
|
106
|
+
await mount({}, '/settings/roles');
|
|
107
|
+
const trail = await screen.findByRole('navigation', { name: 'Breadcrumb' });
|
|
108
|
+
expect(
|
|
109
|
+
await within(trail).findByRole('link', { name: 'System settings' }),
|
|
110
|
+
).toHaveAttribute('href', '/main/settings');
|
|
111
|
+
expect(within(trail).getByText('Roles and permissions')).toBeVisible();
|
|
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
|
+
);
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineAppRoutes,
|
|
3
|
+
resolveAppClientContributions,
|
|
4
|
+
type AppClientRegisteredRoute,
|
|
5
|
+
} from '@nocobase/app-client/plugins';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import foundation from '@nocobase/app-plugin-foundation/client/routes';
|
|
8
|
+
import settings from '../../client/pages/settings/routes.js';
|
|
9
|
+
import shell from '../../client/pages/settings/shell-routes.js';
|
|
10
|
+
import { createApplicationRoutes } from '../../client/routes.js';
|
|
11
|
+
import {
|
|
12
|
+
buildRouteNavigation,
|
|
13
|
+
buildSettingsCatalog,
|
|
14
|
+
filterRouteTree,
|
|
15
|
+
selectedNavigationId,
|
|
16
|
+
routeKey,
|
|
17
|
+
} from '../../client/routing/route-navigation.js';
|
|
18
|
+
|
|
19
|
+
const componentLoader = async () => ({ default: () => null });
|
|
20
|
+
const app = '@nocobase/app-template-default';
|
|
21
|
+
|
|
22
|
+
function resolve(...extra: ReturnType<typeof defineAppRoutes>[]) {
|
|
23
|
+
return resolveAppClientContributions([
|
|
24
|
+
{
|
|
25
|
+
packageName: app,
|
|
26
|
+
source: 'application',
|
|
27
|
+
routes: [shell, settings, ...extra],
|
|
28
|
+
},
|
|
29
|
+
]).routes;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function find(
|
|
33
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
34
|
+
name: string,
|
|
35
|
+
): AppClientRegisteredRoute {
|
|
36
|
+
for (const route of routes) {
|
|
37
|
+
if (route.name === name) return route;
|
|
38
|
+
const nested = route.children && find(route.children, name);
|
|
39
|
+
if (nested) return nested;
|
|
40
|
+
}
|
|
41
|
+
return undefined as unknown as AppClientRegisteredRoute;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const catalog = (
|
|
45
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
46
|
+
denied = new Set<string>(),
|
|
47
|
+
) =>
|
|
48
|
+
buildSettingsCatalog(routes, denied).map((category) => [
|
|
49
|
+
category.route.name,
|
|
50
|
+
category.modules.map((module) => [module.route.name, module.target.path]),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
describe('System settings navigation', () => {
|
|
54
|
+
it('keeps the existing URLs and authorization of the settings pages', () => {
|
|
55
|
+
const routes = resolve();
|
|
56
|
+
expect(
|
|
57
|
+
[
|
|
58
|
+
'settingsHome',
|
|
59
|
+
'settingsUsers',
|
|
60
|
+
'settingsRoles',
|
|
61
|
+
'settingsDefaultAccess',
|
|
62
|
+
].map((name) => {
|
|
63
|
+
const route = find(routes, name);
|
|
64
|
+
return [name, route.path, route.authz];
|
|
65
|
+
}),
|
|
66
|
+
).toEqual([
|
|
67
|
+
['settingsHome', '/settings', 'skip'],
|
|
68
|
+
[
|
|
69
|
+
'settingsUsers',
|
|
70
|
+
'/settings/users',
|
|
71
|
+
{ resource: { type: 'user', id: '*' }, action: 'read' },
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
'settingsRoles',
|
|
75
|
+
'/settings/roles',
|
|
76
|
+
{
|
|
77
|
+
resource: { type: 'settings', id: 'authorization.permission-sets' },
|
|
78
|
+
action: 'read',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
[
|
|
82
|
+
'settingsDefaultAccess',
|
|
83
|
+
'/settings/default-access',
|
|
84
|
+
{
|
|
85
|
+
resource: { type: 'settings', id: 'authorization.default-access' },
|
|
86
|
+
action: 'read',
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
]);
|
|
90
|
+
expect(find(routes, 'settingsUsers').id).toBe(`${app}:settingsUsers`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('shows one sidebar entry that opens the home and stays selected on every settings page', () => {
|
|
94
|
+
const routes = resolve();
|
|
95
|
+
const menu = buildRouteNavigation(routes, new Set());
|
|
96
|
+
expect(menu.map(({ route }) => [route.name, route.path])).toEqual([
|
|
97
|
+
['settingsHome', '/settings'],
|
|
98
|
+
]);
|
|
99
|
+
expect(menu[0].children).toEqual([]);
|
|
100
|
+
expect(menu[0].route.navigation?.title).toBe('settings.title');
|
|
101
|
+
const entry = routeKey(menu[0].route);
|
|
102
|
+
for (const path of [
|
|
103
|
+
'/settings',
|
|
104
|
+
'/settings/users',
|
|
105
|
+
'/settings/users/create',
|
|
106
|
+
'/settings/roles/member/edit',
|
|
107
|
+
'/settings/default-access',
|
|
108
|
+
])
|
|
109
|
+
expect(selectedNavigationId(routes, path)).toBe(entry);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('groups modules into categories, opens a tabbed module on its first readable tab and hides empty categories', () => {
|
|
113
|
+
const routes = resolve();
|
|
114
|
+
expect(catalog(routes)).toEqual([
|
|
115
|
+
[
|
|
116
|
+
'settingsAccount',
|
|
117
|
+
[
|
|
118
|
+
['settingsUsers', '/settings/users'],
|
|
119
|
+
['permissionManagement', '/settings/roles'],
|
|
120
|
+
],
|
|
121
|
+
],
|
|
122
|
+
]);
|
|
123
|
+
const roles = find(routes, 'settingsRoles');
|
|
124
|
+
expect(catalog(routes, new Set([routeKey(roles)]))).toEqual([
|
|
125
|
+
[
|
|
126
|
+
'settingsAccount',
|
|
127
|
+
[
|
|
128
|
+
['settingsUsers', '/settings/users'],
|
|
129
|
+
['permissionManagement', '/settings/default-access'],
|
|
130
|
+
],
|
|
131
|
+
],
|
|
132
|
+
]);
|
|
133
|
+
const denied = new Set(
|
|
134
|
+
['settingsUsers', 'settingsRoles', 'settingsDefaultAccess'].map((name) =>
|
|
135
|
+
routeKey(find(routes, name)),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
expect(catalog(routes, denied)).toEqual([]);
|
|
139
|
+
// Without a module to open, the sidebar entry disappears too.
|
|
140
|
+
expect(buildRouteNavigation(routes, denied)).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('adds a later module with several views to its category without another sidebar entry', () => {
|
|
144
|
+
const routes = resolve(
|
|
145
|
+
defineAppRoutes([
|
|
146
|
+
{
|
|
147
|
+
parent: 'settingsSystem',
|
|
148
|
+
name: 'notificationManagement',
|
|
149
|
+
navigation: {
|
|
150
|
+
title: 'Notifications',
|
|
151
|
+
description: 'Channels and delivery records.',
|
|
152
|
+
},
|
|
153
|
+
breadcrumb: { title: 'Notifications' },
|
|
154
|
+
children: [
|
|
155
|
+
{
|
|
156
|
+
name: 'notificationLogs',
|
|
157
|
+
path: '/notifications/logs',
|
|
158
|
+
authz: {
|
|
159
|
+
resource: { type: 'page', id: 'notification.logs' },
|
|
160
|
+
action: 'access',
|
|
161
|
+
},
|
|
162
|
+
navigation: { title: 'Delivery logs' },
|
|
163
|
+
componentLoader,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
]),
|
|
168
|
+
);
|
|
169
|
+
expect(find(routes, 'notificationLogs').path).toBe(
|
|
170
|
+
'/settings/notifications/logs',
|
|
171
|
+
);
|
|
172
|
+
expect(catalog(routes).at(-1)).toEqual([
|
|
173
|
+
'settingsSystem',
|
|
174
|
+
[['notificationManagement', '/settings/notifications/logs']],
|
|
175
|
+
]);
|
|
176
|
+
expect(buildRouteNavigation(routes, new Set())).toHaveLength(1);
|
|
177
|
+
expect(selectedNavigationId(routes, '/settings/notifications/logs')).toBe(
|
|
178
|
+
routeKey(buildRouteNavigation(routes, new Set())[0].route),
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('places a plugin module registered outside the categories under System', () => {
|
|
183
|
+
const routes = resolveAppClientContributions([
|
|
184
|
+
{ packageName: app, source: 'application', routes: [shell] },
|
|
185
|
+
{
|
|
186
|
+
packageName: '@nocobase/app-plugin-foundation',
|
|
187
|
+
source: 'plugin',
|
|
188
|
+
routes: foundation,
|
|
189
|
+
},
|
|
190
|
+
]).routes;
|
|
191
|
+
// The bare shell: only App Foundation, which only an unrestricted administrator may open.
|
|
192
|
+
const menu = buildRouteNavigation(routes, new Set());
|
|
193
|
+
expect(menu.map(({ route }) => route.name)).toEqual(['settingsHome']);
|
|
194
|
+
expect(catalog(routes)).toEqual([
|
|
195
|
+
['settingsSystem', [['foundation', '/foundation']]],
|
|
196
|
+
]);
|
|
197
|
+
// The signed-in shell receives the tree filtered to its pages; empty categories keep their titles there.
|
|
198
|
+
const signedIn = filterRouteTree(
|
|
199
|
+
routes,
|
|
200
|
+
(route) => route.auth === 'required',
|
|
201
|
+
);
|
|
202
|
+
expect(catalog(signedIn)).toEqual([
|
|
203
|
+
['settingsSystem', [['foundation', '/foundation']]],
|
|
204
|
+
]);
|
|
205
|
+
const page = find(routes, 'foundation');
|
|
206
|
+
expect(page.navigation?.description).toBe('nav.foundationDescription');
|
|
207
|
+
expect(buildRouteNavigation(routes, new Set([routeKey(page)]))).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('shows the notification template view as a System card once an invitation mailer is configured', () => {
|
|
211
|
+
const routes = resolveAppClientContributions([
|
|
212
|
+
{
|
|
213
|
+
packageName: app,
|
|
214
|
+
source: 'application',
|
|
215
|
+
routes: createApplicationRoutes({
|
|
216
|
+
notificationTemplates: { invitationConfigured: true },
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
219
|
+
]).routes;
|
|
220
|
+
expect(catalog(routes)).toEqual([
|
|
221
|
+
[
|
|
222
|
+
'settingsSystem',
|
|
223
|
+
[['notificationManagement', '/settings/notifications/templates']],
|
|
224
|
+
],
|
|
225
|
+
]);
|
|
226
|
+
expect(
|
|
227
|
+
buildRouteNavigation(routes, new Set()).map(({ route }) => route.name),
|
|
228
|
+
).toEqual(['home', 'settingsHome']);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -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>
|