@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
|
@@ -8,6 +8,8 @@ import type { ReactElement, ReactNode } from 'react';
|
|
|
8
8
|
import { createMemoryRouter, Outlet, RouterProvider } from 'react-router';
|
|
9
9
|
import { describe, expect, it } from 'vitest';
|
|
10
10
|
|
|
11
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
12
|
+
import { RouteTreeProvider } from '../../client/routing/route-context.js';
|
|
11
13
|
import { BackButton } from '#components/back-button';
|
|
12
14
|
|
|
13
15
|
import enUS from '../../client/locales/en-US.js';
|
|
@@ -41,6 +43,25 @@ function renderAt(url: string, child: ReactElement) {
|
|
|
41
43
|
],
|
|
42
44
|
{ initialEntries: [url] },
|
|
43
45
|
);
|
|
46
|
+
const registered = ['/projects', '/overview'].map(
|
|
47
|
+
(path) =>
|
|
48
|
+
({
|
|
49
|
+
path,
|
|
50
|
+
id: path,
|
|
51
|
+
name: path,
|
|
52
|
+
packageName: 'test',
|
|
53
|
+
auth: 'required',
|
|
54
|
+
authz: 'skip',
|
|
55
|
+
componentLoader: async () => ({ default: () => null }),
|
|
56
|
+
}) as AppClientRegisteredRoute,
|
|
57
|
+
);
|
|
58
|
+
// The provider needs to be within the router, so wrap each route's page.
|
|
59
|
+
const routes = router.routes;
|
|
60
|
+
for (const entry of routes) {
|
|
61
|
+
entry.element = (
|
|
62
|
+
<RouteTreeProvider routes={registered}>{entry.element}</RouteTreeProvider>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
44
65
|
render(<RouterProvider router={router} />, { wrapper: I18n });
|
|
45
66
|
return router;
|
|
46
67
|
}
|
|
@@ -77,4 +98,17 @@ describe('BackButton', () => {
|
|
|
77
98
|
screen.getByRole('link', { name: 'Back to the overview' }),
|
|
78
99
|
).toHaveAttribute('href', '/overview');
|
|
79
100
|
});
|
|
101
|
+
it.each(['https://example.com/overview', '//example.com', '/missing'])(
|
|
102
|
+
'falls back safely for %s without naming the invalid destination',
|
|
103
|
+
(to) => {
|
|
104
|
+
renderAt(
|
|
105
|
+
'/projects/12',
|
|
106
|
+
<BackButton to={to}>Private destination</BackButton>,
|
|
107
|
+
);
|
|
108
|
+
expect(
|
|
109
|
+
screen.getByRole('link', { name: enUS.navigation.back }),
|
|
110
|
+
).toHaveAttribute('href', '/projects');
|
|
111
|
+
expect(screen.queryByText('Private destination')).not.toBeInTheDocument();
|
|
112
|
+
},
|
|
113
|
+
);
|
|
80
114
|
});
|
|
@@ -2,7 +2,13 @@ import {
|
|
|
2
2
|
TestI18nProvider,
|
|
3
3
|
createTestI18nRuntime,
|
|
4
4
|
} from '@nocobase/i18n/testing';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
screen,
|
|
9
|
+
waitFor,
|
|
10
|
+
within,
|
|
11
|
+
} from '@testing-library/react';
|
|
6
12
|
import type { ReactElement } from 'react';
|
|
7
13
|
import { MemoryRouter, Route, Routes } from 'react-router';
|
|
8
14
|
import { describe, expect, it, vi } from 'vitest';
|
|
@@ -98,6 +104,9 @@ describe('application authentication pages', () => {
|
|
|
98
104
|
});
|
|
99
105
|
expect(panel).toHaveClass('hidden', 'xl:block');
|
|
100
106
|
expect(panel).toHaveTextContent('AI-native application platform');
|
|
107
|
+
expect(within(panel).getByRole('heading', { level: 2 })).toHaveTextContent(
|
|
108
|
+
/^AI \+ NocoBase\s*Build reliable business systems$/,
|
|
109
|
+
);
|
|
101
110
|
expect(panel).toHaveTextContent('AI-native frontend');
|
|
102
111
|
expect(panel).toHaveTextContent('NocoBase foundation');
|
|
103
112
|
expect(panel).toHaveTextContent('Freedom above. Confidence below.');
|
|
@@ -2,7 +2,8 @@ import type {
|
|
|
2
2
|
AppClientRegisteredRoute,
|
|
3
3
|
AppClientRouteComponentModule,
|
|
4
4
|
} from '@nocobase/app-client/plugins';
|
|
5
|
-
import { act, screen } from '@testing-library/react';
|
|
5
|
+
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
|
6
|
+
import { Activity, useState } from 'react';
|
|
6
7
|
import { describe, expect, it } from 'vitest';
|
|
7
8
|
|
|
8
9
|
import { ClientRoute } from '../../client/routing/client-route.tsx';
|
|
@@ -49,6 +50,97 @@ const healthyModule: AppClientRouteComponentModule = {
|
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
describe('client route loading recovery', () => {
|
|
53
|
+
it('keeps a loaded wrapped component when an ancestor Activity reconnects its effects', async () => {
|
|
54
|
+
let loads = 0;
|
|
55
|
+
const componentLoader = async () => {
|
|
56
|
+
loads += 1;
|
|
57
|
+
return {
|
|
58
|
+
default: function Editor() {
|
|
59
|
+
const [draft, setDraft] = useState('');
|
|
60
|
+
return (
|
|
61
|
+
<input
|
|
62
|
+
aria-label='Draft'
|
|
63
|
+
value={draft}
|
|
64
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
const definition = { ...route, componentLoader };
|
|
71
|
+
const view = (hidden: boolean) => (
|
|
72
|
+
<Activity mode={hidden ? 'hidden' : 'visible'}>
|
|
73
|
+
<ClientRoute route={definition} />
|
|
74
|
+
</Activity>
|
|
75
|
+
);
|
|
76
|
+
const { rerender } = await renderWithApp(view(false), options);
|
|
77
|
+
const input = await screen.findByRole(
|
|
78
|
+
'textbox',
|
|
79
|
+
{ name: 'Draft' },
|
|
80
|
+
ROUTE_LOAD_TIMEOUT,
|
|
81
|
+
);
|
|
82
|
+
fireEvent.change(input, { target: { value: 'Keep this draft' } });
|
|
83
|
+
rerender(view(true));
|
|
84
|
+
await waitFor(() => expect(input).not.toBeVisible());
|
|
85
|
+
rerender(view(false));
|
|
86
|
+
await waitFor(() => expect(input).toBeVisible());
|
|
87
|
+
expect(screen.getByRole('textbox', { name: 'Draft' })).toBe(input);
|
|
88
|
+
expect(input).toHaveValue('Keep this draft');
|
|
89
|
+
expect(loads).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('loads a replacement after a successful module and ignores a superseded successful response', async () => {
|
|
93
|
+
const pending = Promise.withResolvers<AppClientRouteComponentModule>();
|
|
94
|
+
const { rerender } = await renderWithApp(
|
|
95
|
+
<ClientRoute
|
|
96
|
+
route={{ ...route, componentLoader: async () => healthyModule }}
|
|
97
|
+
/>,
|
|
98
|
+
options,
|
|
99
|
+
);
|
|
100
|
+
await screen.findByText('Recovered page', {}, ROUTE_LOAD_TIMEOUT);
|
|
101
|
+
rerender(
|
|
102
|
+
<ClientRoute
|
|
103
|
+
route={{ ...route, componentLoader: () => pending.promise }}
|
|
104
|
+
/>,
|
|
105
|
+
);
|
|
106
|
+
const replacement = { default: () => <h2>Replacement page</h2> };
|
|
107
|
+
rerender(
|
|
108
|
+
<ClientRoute
|
|
109
|
+
route={{ ...route, componentLoader: async () => replacement }}
|
|
110
|
+
/>,
|
|
111
|
+
);
|
|
112
|
+
await screen.findByText('Replacement page', {}, ROUTE_LOAD_TIMEOUT);
|
|
113
|
+
await act(async () =>
|
|
114
|
+
pending.resolve({ default: () => <h2>Stale page</h2> }),
|
|
115
|
+
);
|
|
116
|
+
expect(screen.getByText('Replacement page')).toBeVisible();
|
|
117
|
+
expect(screen.queryByText('Stale page')).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('invokes the same loader again on retry after a failed attempt', async () => {
|
|
121
|
+
let loads = 0;
|
|
122
|
+
const componentLoader = async () => {
|
|
123
|
+
loads += 1;
|
|
124
|
+
if (loads === 1) throw new Error('Temporary failure');
|
|
125
|
+
return healthyModule;
|
|
126
|
+
};
|
|
127
|
+
await renderWithApp(
|
|
128
|
+
<ClientRoute route={{ ...route, componentLoader }} />,
|
|
129
|
+
options,
|
|
130
|
+
);
|
|
131
|
+
fireEvent.click(
|
|
132
|
+
await screen.findByRole(
|
|
133
|
+
'button',
|
|
134
|
+
{ name: 'Retry', exact: true },
|
|
135
|
+
ROUTE_LOAD_TIMEOUT,
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
expect(
|
|
139
|
+
await screen.findByText('Recovered page', {}, ROUTE_LOAD_TIMEOUT),
|
|
140
|
+
).toBeVisible();
|
|
141
|
+
expect(loads).toBe(2);
|
|
142
|
+
});
|
|
143
|
+
|
|
52
144
|
it('clears a previous failure when the same mounted route loads successfully', async () => {
|
|
53
145
|
const { rerender } = await renderWithApp(
|
|
54
146
|
<ClientRoute route={route} />,
|
|
@@ -6,9 +6,139 @@ import {
|
|
|
6
6
|
import { describe, expect, it } from 'vitest';
|
|
7
7
|
|
|
8
8
|
import systemSettingsRoutes from '../../client/pages/settings/routes.js';
|
|
9
|
-
import applicationRoutes
|
|
9
|
+
import applicationRoutes, {
|
|
10
|
+
createApplicationRoutes,
|
|
11
|
+
} from '../../client/routes.ts';
|
|
12
|
+
import { includeNotificationTemplateRoutes } from '../../client/pages/settings/notification-routes.js';
|
|
13
|
+
import { defineAppRoutes } from '@nocobase/app-client/plugins';
|
|
10
14
|
|
|
11
15
|
describe('app client routes', () => {
|
|
16
|
+
it('joins the settings system category across contributions without moving the log URL', () => {
|
|
17
|
+
const shell = defineAppRoutes([
|
|
18
|
+
{
|
|
19
|
+
name: 'settings',
|
|
20
|
+
path: '/settings',
|
|
21
|
+
navigation: { title: 'Settings' },
|
|
22
|
+
children: [
|
|
23
|
+
{
|
|
24
|
+
name: 'settingsSystem',
|
|
25
|
+
navigation: { title: 'System' },
|
|
26
|
+
children: [],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
]);
|
|
31
|
+
const logs = defineAppRoutes([
|
|
32
|
+
{
|
|
33
|
+
name: 'notificationManagement',
|
|
34
|
+
parent: 'settingsSystem',
|
|
35
|
+
navigation: { title: 'Notifications' },
|
|
36
|
+
children: [
|
|
37
|
+
{
|
|
38
|
+
name: 'notificationLogs',
|
|
39
|
+
path: '/notifications/logs',
|
|
40
|
+
auth: 'required',
|
|
41
|
+
authz: {
|
|
42
|
+
resource: { type: 'page', id: 'notification.logs' },
|
|
43
|
+
action: 'access',
|
|
44
|
+
},
|
|
45
|
+
componentLoader: () =>
|
|
46
|
+
import('../../client/pages/settings/notification-templates.js'),
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
]);
|
|
51
|
+
const routes = includeNotificationTemplateRoutes([shell, logs], true);
|
|
52
|
+
const built = resolveAppClientContributions([
|
|
53
|
+
{
|
|
54
|
+
packageName: '@nocobase/app-template-default',
|
|
55
|
+
source: 'application',
|
|
56
|
+
routes,
|
|
57
|
+
},
|
|
58
|
+
]);
|
|
59
|
+
expect(pagePaths(built.routes)).toEqual([
|
|
60
|
+
'/settings/notifications/logs',
|
|
61
|
+
'/settings/notifications/templates',
|
|
62
|
+
]);
|
|
63
|
+
expect(built.routes[0].children[0].children).toHaveLength(1);
|
|
64
|
+
const created = includeNotificationTemplateRoutes([shell], true);
|
|
65
|
+
expect(
|
|
66
|
+
created
|
|
67
|
+
.flatMap((part) => part.routes)
|
|
68
|
+
.find((route) => route.name === 'notificationManagement')?.parent,
|
|
69
|
+
).toBe('settingsSystem');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('exposes configured invitation templates through the real application route factory', async () => {
|
|
73
|
+
const built = resolveAppClientContributions([
|
|
74
|
+
{
|
|
75
|
+
packageName: '@nocobase/app-template-default',
|
|
76
|
+
source: 'application',
|
|
77
|
+
routes: createApplicationRoutes({
|
|
78
|
+
notificationTemplates: { invitationConfigured: true },
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
]);
|
|
82
|
+
expect(pagePaths(built.routes)).toContain(
|
|
83
|
+
'/settings/notifications/templates',
|
|
84
|
+
);
|
|
85
|
+
expect(pageAuthorizations(built.routes)).toContainEqual({
|
|
86
|
+
name: 'notificationTemplates',
|
|
87
|
+
authorizedAs: 'notification.templates',
|
|
88
|
+
});
|
|
89
|
+
for (const loader of componentLoadersIn(built.routes))
|
|
90
|
+
await expect(loader()).resolves.toMatchObject({
|
|
91
|
+
default: expect.any(Function),
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('reuses the notification module and preserves its logs and existing grants', () => {
|
|
96
|
+
const existing = defineAppRoutes([
|
|
97
|
+
{
|
|
98
|
+
name: 'settings',
|
|
99
|
+
path: '/settings',
|
|
100
|
+
navigation: { title: 'Settings' },
|
|
101
|
+
children: [
|
|
102
|
+
{
|
|
103
|
+
name: 'notificationManagement',
|
|
104
|
+
navigation: { title: 'Notifications' },
|
|
105
|
+
children: [
|
|
106
|
+
{
|
|
107
|
+
name: 'notificationLogs',
|
|
108
|
+
path: '/notification-logs',
|
|
109
|
+
auth: 'required',
|
|
110
|
+
authz: {
|
|
111
|
+
resource: { type: 'page', id: 'existing.notification.logs' },
|
|
112
|
+
action: 'access',
|
|
113
|
+
},
|
|
114
|
+
componentLoader: () =>
|
|
115
|
+
import('../../client/pages/settings/notification-templates.js'),
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
]);
|
|
122
|
+
const once = includeNotificationTemplateRoutes([existing], false);
|
|
123
|
+
expect(includeNotificationTemplateRoutes(once, true)).toBe(once);
|
|
124
|
+
const built = resolveAppClientContributions([
|
|
125
|
+
{
|
|
126
|
+
packageName: '@nocobase/app-template-default',
|
|
127
|
+
source: 'application',
|
|
128
|
+
routes: once,
|
|
129
|
+
},
|
|
130
|
+
]);
|
|
131
|
+
expect(pagePaths(built.routes)).toEqual([
|
|
132
|
+
'/settings/notification-logs',
|
|
133
|
+
'/settings/notifications/templates',
|
|
134
|
+
]);
|
|
135
|
+
expect(pageAuthorizations(built.routes)).toEqual([
|
|
136
|
+
{ name: 'notificationLogs', authorizedAs: 'existing.notification.logs' },
|
|
137
|
+
{ name: 'notificationTemplates', authorizedAs: 'notification.templates' },
|
|
138
|
+
]);
|
|
139
|
+
expect(built.routes[0].children).toHaveLength(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
12
142
|
it('keeps the landing page and the authentication pages', () => {
|
|
13
143
|
// The authentication plugin and this application have to agree on these paths: it sends an unknown visitor to
|
|
14
144
|
// /login, sends a signed-in user who opens a guest page back to /, and mails a reset link to /reset-password,
|
|
@@ -55,15 +185,17 @@ describe('app client routes', () => {
|
|
|
55
185
|
expect(pageAuthorizations(resolved.routes)).toEqual([
|
|
56
186
|
{ name: 'home', authorizedAs: null },
|
|
57
187
|
{ name: 'inbox', authorizedAs: null },
|
|
188
|
+
{ name: 'settingsHome', authorizedAs: null },
|
|
58
189
|
]);
|
|
59
190
|
});
|
|
60
191
|
|
|
61
|
-
it('keeps a bare application
|
|
192
|
+
it('keeps a bare application free of settings modules and supports settings registration as business capabilities are developed', async () => {
|
|
193
|
+
// Only the settings home, which lists what the viewer may open and grants nothing itself.
|
|
62
194
|
expect(
|
|
63
|
-
pagePaths(resolveRoutes().routes).
|
|
195
|
+
pagePaths(resolveRoutes().routes).filter((path) =>
|
|
64
196
|
path.startsWith('/settings'),
|
|
65
197
|
),
|
|
66
|
-
).
|
|
198
|
+
).toEqual(['/settings']);
|
|
67
199
|
const built = resolveAppClientContributions([
|
|
68
200
|
{
|
|
69
201
|
packageName: '@nocobase/app-template-default',
|
|
@@ -74,6 +206,7 @@ describe('app client routes', () => {
|
|
|
74
206
|
expect(pageAuthorizations(built.routes)).toEqual([
|
|
75
207
|
{ name: 'home', authorizedAs: null },
|
|
76
208
|
{ name: 'inbox', authorizedAs: null },
|
|
209
|
+
{ name: 'settingsHome', authorizedAs: null },
|
|
77
210
|
{ name: 'settingsUsers', authorizedAs: 'user:*' },
|
|
78
211
|
{ name: 'settingsUserCreate', authorizedAs: 'user:*' },
|
|
79
212
|
{ name: 'settingsUser', authorizedAs: 'user:*' },
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
import {
|
|
6
6
|
apiClientToken,
|
|
7
7
|
ClientApplicationContext,
|
|
8
|
+
NavigationGuardProvider,
|
|
8
9
|
type ClientApplication,
|
|
9
10
|
createAppClientConfig,
|
|
10
11
|
} from '@nocobase/app-client';
|
|
@@ -89,15 +90,15 @@ describe('application shell', () => {
|
|
|
89
90
|
expect(
|
|
90
91
|
screen.queryByRole('link', { name: 'Settings' }),
|
|
91
92
|
).not.toBeInTheDocument();
|
|
92
|
-
expect(screen.getByText('
|
|
93
|
+
expect(screen.getByText('Build reliable business systems')).toBeVisible();
|
|
93
94
|
expect(screen.getByRole('link', { name: 'NocoBase' })).toHaveAttribute(
|
|
94
95
|
'href',
|
|
95
96
|
'https://www.nocobase.com',
|
|
96
97
|
);
|
|
97
98
|
expect(
|
|
98
99
|
screen.getByText('NocoBase', { selector: 'a' }).parentElement,
|
|
99
|
-
).toHaveTextContent('
|
|
100
|
-
expect(screen.
|
|
100
|
+
).toHaveTextContent('AI + NocoBase');
|
|
101
|
+
expect(screen.queryByText('Default Template')).not.toBeInTheDocument();
|
|
101
102
|
expect(screen.getByText('v0.0.0')).toHaveClass('truncate');
|
|
102
103
|
// shadcn's edge rail, with a translated label in place of its built-in one.
|
|
103
104
|
expect(
|
|
@@ -201,13 +202,25 @@ describe('application shell', () => {
|
|
|
201
202
|
expect(
|
|
202
203
|
await screen.findByRole('button', { name: 'Expand navigation' }),
|
|
203
204
|
).toHaveAttribute('aria-pressed', 'true');
|
|
204
|
-
// The footer keeps only its shield; focusing it shows the slogan
|
|
205
|
+
// The footer keeps only its shield; focusing it shows the slogan and version.
|
|
205
206
|
const footerIcon = screen.getByRole('img', {
|
|
206
|
-
name: 'AI
|
|
207
|
+
name: 'AI + NocoBase Build reliable business systems v0.0.0',
|
|
207
208
|
});
|
|
208
209
|
act(() => footerIcon.focus());
|
|
209
210
|
expect(
|
|
210
|
-
await screen.findByText('
|
|
211
|
+
await screen.findByText('v0.0.0', {
|
|
212
|
+
selector: '[data-slot="tooltip-content"] span',
|
|
213
|
+
}),
|
|
214
|
+
).toBeVisible();
|
|
215
|
+
|
|
216
|
+
expect(screen.queryByText(/Default Template/)).not.toBeInTheDocument();
|
|
217
|
+
expect(
|
|
218
|
+
screen.getByText('AI + NocoBase', {
|
|
219
|
+
selector: '[data-slot="tooltip-content"] span',
|
|
220
|
+
}),
|
|
221
|
+
).toBeVisible();
|
|
222
|
+
expect(
|
|
223
|
+
screen.getByText('Build reliable business systems', {
|
|
211
224
|
selector: '[data-slot="tooltip-content"] span',
|
|
212
225
|
}),
|
|
213
226
|
).toBeVisible();
|
|
@@ -223,7 +236,7 @@ describe('application shell', () => {
|
|
|
223
236
|
),
|
|
224
237
|
);
|
|
225
238
|
expect(
|
|
226
|
-
screen.queryByRole('img', { name: /
|
|
239
|
+
screen.queryByRole('img', { name: /AI \+ NocoBase/ }),
|
|
227
240
|
).not.toBeInTheDocument();
|
|
228
241
|
});
|
|
229
242
|
|
|
@@ -316,7 +329,9 @@ function renderApplication(
|
|
|
316
329
|
<SidebarPreferenceProvider
|
|
317
330
|
value={createAppSidebarPreferenceStore('/main')}
|
|
318
331
|
>
|
|
319
|
-
<
|
|
332
|
+
<NavigationGuardProvider>
|
|
333
|
+
<AppRouter clientRoutes={clientRoutes} />
|
|
334
|
+
</NavigationGuardProvider>
|
|
320
335
|
</SidebarPreferenceProvider>
|
|
321
336
|
</AppThemeProvider>
|
|
322
337
|
</MemoryRouter>
|
|
@@ -2,10 +2,14 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { resolveAppRuntime } from '@nocobase/app-client/runtime';
|
|
3
3
|
import { defineAppRuntime } from '@nocobase/app-client/runtime';
|
|
4
4
|
import appRuntime from '../../client/runtime.js';
|
|
5
|
+
import { createApplicationRoutes } from '../../client/routes.js';
|
|
5
6
|
import cliPlugins from '../../cli/plugins.js';
|
|
6
7
|
import serverPlugins from '../../server/plugins.js';
|
|
7
8
|
import settingsRoutes from '../../client/pages/settings/routes.js';
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
buildRouteNavigation,
|
|
11
|
+
buildSettingsCatalog,
|
|
12
|
+
} from '../../client/routing/route-navigation.js';
|
|
9
13
|
|
|
10
14
|
describe('default Foundation integration', () => {
|
|
11
15
|
it('registers the Foundation deployment evidence build hook', () => {
|
|
@@ -15,11 +19,18 @@ describe('default Foundation integration', () => {
|
|
|
15
19
|
),
|
|
16
20
|
).toHaveLength(1);
|
|
17
21
|
});
|
|
18
|
-
it('exposes Foundation in settings for an unrestricted administrator and removes the
|
|
22
|
+
it('exposes Foundation in System settings for an unrestricted administrator and removes the entry when denied', async () => {
|
|
19
23
|
const runtime = await resolveAppRuntime(appRuntime);
|
|
20
24
|
const menu = buildRouteNavigation(runtime.routes, new Set());
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
// One System settings entry; Foundation is a card on its home, not a sidebar entry of its own.
|
|
26
|
+
expect(menu.map(({ route }) => route.name)).toEqual([
|
|
27
|
+
'home',
|
|
28
|
+
'settingsHome',
|
|
29
|
+
]);
|
|
30
|
+
const category = buildSettingsCatalog(runtime.routes, new Set()).find(
|
|
31
|
+
({ route }) => route.name === 'settingsSystem',
|
|
32
|
+
);
|
|
33
|
+
const page = category?.modules.find(
|
|
23
34
|
({ route }) => route.name === 'foundation',
|
|
24
35
|
)?.route;
|
|
25
36
|
expect(page).toMatchObject({
|
|
@@ -28,12 +39,13 @@ describe('default Foundation integration', () => {
|
|
|
28
39
|
authz: 'unrestricted',
|
|
29
40
|
});
|
|
30
41
|
if (!page) throw new Error('Foundation route missing');
|
|
42
|
+
const denied = new Set([`${page.packageName}:${page.path}:${page.id}`]);
|
|
31
43
|
expect(
|
|
32
|
-
buildRouteNavigation(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
).
|
|
44
|
+
buildRouteNavigation(runtime.routes, denied).map(
|
|
45
|
+
({ route }) => route.name,
|
|
46
|
+
),
|
|
47
|
+
).toEqual(['home']);
|
|
48
|
+
expect(buildSettingsCatalog(runtime.routes, denied)).toEqual([]);
|
|
37
49
|
expect(
|
|
38
50
|
serverPlugins.plugins.filter(
|
|
39
51
|
(plugin) => plugin.packageName === '@nocobase/app-plugin-foundation',
|
|
@@ -41,22 +53,31 @@ describe('default Foundation integration', () => {
|
|
|
41
53
|
).toHaveLength(1);
|
|
42
54
|
});
|
|
43
55
|
|
|
44
|
-
it('
|
|
56
|
+
it('keeps Foundation beside business settings without changing its protected URL', async () => {
|
|
45
57
|
const runtime = await resolveAppRuntime(
|
|
46
58
|
defineAppRuntime({
|
|
47
59
|
...appRuntime,
|
|
48
|
-
routes: [...
|
|
60
|
+
routes: () => [...createApplicationRoutes(), settingsRoutes],
|
|
49
61
|
}),
|
|
50
62
|
);
|
|
51
63
|
const menu = buildRouteNavigation(runtime.routes, new Set());
|
|
52
64
|
expect(menu.some(({ route }) => route.name === 'foundationSettings')).toBe(
|
|
53
65
|
false,
|
|
54
66
|
);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
expect(
|
|
68
|
+
buildSettingsCatalog(runtime.routes, new Set()).map((category) => [
|
|
69
|
+
category.route.name,
|
|
70
|
+
category.modules.map(({ route, target }) => [route.name, target.path]),
|
|
71
|
+
]),
|
|
72
|
+
).toEqual([
|
|
73
|
+
[
|
|
74
|
+
'settingsAccount',
|
|
75
|
+
[
|
|
76
|
+
['settingsUsers', '/settings/users'],
|
|
77
|
+
['permissionManagement', '/settings/roles'],
|
|
78
|
+
],
|
|
79
|
+
],
|
|
80
|
+
['settingsSystem', [['foundation', '/foundation']]],
|
|
81
|
+
]);
|
|
61
82
|
});
|
|
62
83
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ClientApplicationContext,
|
|
3
|
+
NavigationGuardProvider,
|
|
3
4
|
type ClientApplication,
|
|
4
5
|
createAppClientConfig,
|
|
5
6
|
} from '@nocobase/app-client';
|
|
@@ -70,7 +71,9 @@ async function setup(children: ReactNode) {
|
|
|
70
71
|
<I18nProvider runtime={runtime}>
|
|
71
72
|
<ClientApplicationContext.Provider value={app}>
|
|
72
73
|
<SidebarPreferenceProvider value={sidebarPreference}>
|
|
73
|
-
<MemoryRouter initialEntries={['/']}>
|
|
74
|
+
<MemoryRouter initialEntries={['/']}>
|
|
75
|
+
<NavigationGuardProvider>{children}</NavigationGuardProvider>
|
|
76
|
+
</MemoryRouter>
|
|
74
77
|
</SidebarPreferenceProvider>
|
|
75
78
|
</ClientApplicationContext.Provider>
|
|
76
79
|
</I18nProvider>,
|
|
@@ -85,13 +88,15 @@ describe('shell translations', () => {
|
|
|
85
88
|
expect(
|
|
86
89
|
screen.getByRole('navigation', { name: 'Breadcrumb' }),
|
|
87
90
|
).toHaveTextContent('Overview');
|
|
88
|
-
expect(screen.getByText('
|
|
91
|
+
expect(screen.getByText('Build reliable business systems')).toBeVisible();
|
|
92
|
+
expect(screen.queryByText('Default Template')).not.toBeInTheDocument();
|
|
89
93
|
await act(() => runtime.changeLanguage('zh-CN'));
|
|
90
94
|
expect(screen.getByRole('navigation', { name: '面包屑' })).toBeVisible();
|
|
91
|
-
expect(screen.getByText('
|
|
95
|
+
expect(screen.getByText('Build reliable business systems')).toBeVisible();
|
|
96
|
+
expect(screen.queryByText('Default Template')).not.toBeInTheDocument();
|
|
92
97
|
expect(
|
|
93
98
|
screen.getByRole('link', { name: 'NocoBase' }).parentElement,
|
|
94
|
-
).toHaveTextContent('NocoBase
|
|
99
|
+
).toHaveTextContent('AI + NocoBase');
|
|
95
100
|
expect(
|
|
96
101
|
screen.getByRole('button', { name: '展开或收起导航' }),
|
|
97
102
|
).toHaveAttribute('title', '展开或收起导航');
|
|
@@ -97,7 +97,7 @@ describe('shared table and card feedback', () => {
|
|
|
97
97
|
/>,
|
|
98
98
|
options,
|
|
99
99
|
);
|
|
100
|
-
expect(screen.
|
|
100
|
+
expect((await screen.findByRole('status')).textContent).toBe(
|
|
101
101
|
enUS.listFeedback.loading,
|
|
102
102
|
);
|
|
103
103
|
expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible();
|
|
@@ -159,7 +159,7 @@ describe('shared table and card feedback', () => {
|
|
|
159
159
|
expect(
|
|
160
160
|
screen.getByRole('button', { name: 'Search tools' }),
|
|
161
161
|
).toBeVisible();
|
|
162
|
-
expect(screen.
|
|
162
|
+
expect((await screen.findByRole(role)).closest('td')).not.toBeNull();
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
view.rerender(
|
|
@@ -178,9 +178,8 @@ describe('shared table and card feedback', () => {
|
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
it('renders each built-in state as one icon and one short line without inline actions', async () => {
|
|
181
|
-
const view = await renderWithApp(<ListFeedback state='
|
|
181
|
+
const view = await renderWithApp(<ListFeedback state='empty' />, options);
|
|
182
182
|
const states = [
|
|
183
|
-
['loading', 'status'],
|
|
184
183
|
['empty', 'status'],
|
|
185
184
|
['no-results', 'status'],
|
|
186
185
|
['forbidden', 'alert'],
|
|
@@ -273,8 +272,9 @@ describe('shared table and card feedback', () => {
|
|
|
273
272
|
expect(screen.getByRole('alert').textContent).toBe(
|
|
274
273
|
enUS.listFeedback.failed,
|
|
275
274
|
);
|
|
275
|
+
expect(screen.getByRole('button')).toBeDisabled();
|
|
276
276
|
expect(
|
|
277
|
-
screen.
|
|
277
|
+
await screen.findByRole('button', { name: enUS.listFeedback.retrying }),
|
|
278
278
|
).toBeDisabled();
|
|
279
279
|
});
|
|
280
280
|
|