@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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/app-template-default",
|
|
3
3
|
"displayName": "Default Template",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.90",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"prettier": "@nocobase/dev-config/prettier",
|
|
7
7
|
"engines": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"nocobase": {
|
|
11
11
|
"templateKind": "app",
|
|
12
|
-
"defaultTemplateVersion": "1.0.0-beta.
|
|
12
|
+
"defaultTemplateVersion": "1.0.0-beta.90"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@ai-sdk/react": "4.0.131",
|
|
@@ -74,9 +74,9 @@
|
|
|
74
74
|
"elkjs": "^0.10.2",
|
|
75
75
|
"@types/jsdom": "^30.0.0",
|
|
76
76
|
"@noble/hashes": "2.3.0",
|
|
77
|
-
"@nocobase/app-client": "^4.0.0-beta.
|
|
77
|
+
"@nocobase/app-client": "^4.0.0-beta.9",
|
|
78
78
|
"@nocobase/app-plugin-template-print": "0.0.2-beta.2",
|
|
79
|
-
"@nocobase/app-skills": "0.1.0-beta.
|
|
79
|
+
"@nocobase/app-skills": "0.1.0-beta.61",
|
|
80
80
|
"@nocobase/app-testing": "0.1.0-beta.8",
|
|
81
81
|
"@nocobase/app-websocket": "^0.1.0-beta.1",
|
|
82
82
|
"@nocobase/dev-config": "0.1.0-beta.25",
|
|
@@ -143,40 +143,40 @@
|
|
|
143
143
|
"better-auth": "1.7.5",
|
|
144
144
|
"hono": "^4.13.2",
|
|
145
145
|
"@nocobase/concurrency": "^0.1.0-beta.1",
|
|
146
|
-
"@nocobase/app-plugin-concurrency": "^0.1.0-beta.
|
|
147
|
-
"@nocobase/app-plugin-agents": "^2.0.0-beta.
|
|
146
|
+
"@nocobase/app-plugin-concurrency": "^0.1.0-beta.3",
|
|
147
|
+
"@nocobase/app-plugin-agents": "^2.0.0-beta.7",
|
|
148
148
|
"@nocobase/app-plugin-api-keys": "^1.0.0-beta.21",
|
|
149
|
-
"@nocobase/app-plugin-authentication": "^2.0.0-beta.
|
|
150
|
-
"@nocobase/app-plugin-authorization": "^1.0.0-beta.
|
|
149
|
+
"@nocobase/app-plugin-authentication": "^2.0.0-beta.20",
|
|
150
|
+
"@nocobase/app-plugin-authorization": "^1.0.0-beta.37",
|
|
151
151
|
"@nocobase/app-plugin-database-explorer": "^1.0.0-beta.19",
|
|
152
|
-
"@nocobase/app-plugin-file": "^1.0.0-beta.
|
|
153
|
-
"@nocobase/app-plugin-foundation": "^0.1.0-beta.
|
|
154
|
-
"@nocobase/app-plugin-i18n": "^1.0.0-beta.
|
|
155
|
-
"@nocobase/app-plugin-knowledge": "^0.1.0-beta.
|
|
156
|
-
"@nocobase/app-plugin-mail": "^1.0.0-beta.
|
|
157
|
-
"@nocobase/app-plugin-notification": "^2.0.0-beta.
|
|
158
|
-
"@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.
|
|
159
|
-
"@nocobase/app-plugin-notification-providers": "^1.0.0-beta.
|
|
160
|
-
"@nocobase/app-plugin-scheduler": "^1.0.0-beta.
|
|
161
|
-
"@nocobase/app-plugin-users": "^2.0.0-beta.
|
|
162
|
-
"@nocobase/app-server": "^5.0.0-beta.
|
|
152
|
+
"@nocobase/app-plugin-file": "^1.0.0-beta.30",
|
|
153
|
+
"@nocobase/app-plugin-foundation": "^0.1.0-beta.17",
|
|
154
|
+
"@nocobase/app-plugin-i18n": "^1.0.0-beta.22",
|
|
155
|
+
"@nocobase/app-plugin-knowledge": "^0.1.0-beta.18",
|
|
156
|
+
"@nocobase/app-plugin-mail": "^1.0.0-beta.22",
|
|
157
|
+
"@nocobase/app-plugin-notification": "^2.0.0-beta.6",
|
|
158
|
+
"@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.3",
|
|
159
|
+
"@nocobase/app-plugin-notification-providers": "^1.0.0-beta.19",
|
|
160
|
+
"@nocobase/app-plugin-scheduler": "^1.0.0-beta.24",
|
|
161
|
+
"@nocobase/app-plugin-users": "^2.0.0-beta.17",
|
|
162
|
+
"@nocobase/app-server": "^5.0.0-beta.2",
|
|
163
163
|
"@nocobase/config": "^0.1.0-beta.3",
|
|
164
|
-
"@nocobase/db": "^1.0.0-beta.
|
|
164
|
+
"@nocobase/db": "^1.0.0-beta.26",
|
|
165
165
|
"@nocobase/db-sqlite": "^0.1.0-beta.6",
|
|
166
166
|
"@nocobase/service-provider": "^0.0.2-beta.2",
|
|
167
167
|
"@nocobase/snowflake": "^1.0.0-beta.4",
|
|
168
|
-
"@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.
|
|
169
|
-
"@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.
|
|
170
|
-
"@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.
|
|
168
|
+
"@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.20",
|
|
169
|
+
"@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.19",
|
|
170
|
+
"@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.18",
|
|
171
171
|
"@nocobase/authorization": "^1.0.0-beta.16",
|
|
172
|
-
"@nocobase/queue": "^1.0.0-beta.
|
|
173
|
-
"@nocobase/jobs": "^1.0.0-beta.
|
|
172
|
+
"@nocobase/queue": "^1.0.0-beta.11",
|
|
173
|
+
"@nocobase/jobs": "^1.0.0-beta.6",
|
|
174
174
|
"@nocobase/lifecycle": "^0.1.0-beta.4",
|
|
175
|
-
"@nocobase/i18n": "^2.0.0-beta.
|
|
175
|
+
"@nocobase/i18n": "^2.0.0-beta.2",
|
|
176
176
|
"@nocobase/caching": "^1.0.0-beta.4",
|
|
177
177
|
"@nocobase/repository-input": "^0.1.0-beta.3",
|
|
178
178
|
"@nocobase/app-cli": "^1.0.0-beta.32",
|
|
179
|
-
"@nocobase/realtime": "^1.0.0-beta.
|
|
179
|
+
"@nocobase/realtime": "^1.0.0-beta.2"
|
|
180
180
|
},
|
|
181
181
|
"description": "NocoBase 3 application template with the standard product capabilities.",
|
|
182
182
|
"imports": {
|
package/server/config/auth.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { bearer, deviceAuthorization, username } from 'better-auth/plugins';
|
|
|
12
12
|
*/
|
|
13
13
|
const CLI_CLIENT_IDS: readonly string[] = ['nocobase-cli'];
|
|
14
14
|
|
|
15
|
-
//
|
|
15
|
+
// Select a named sender with passwordReset.mailer; defineAuthConfig retains AUTH_EMAIL_* for legacy inline senders.
|
|
16
16
|
// Automatic recovery requires passwordReset.enabled: true; leave the switch absent for custom callback compatibility.
|
|
17
17
|
const auth: AppConfigFactory<AuthConfig> = defineAuthConfig({
|
|
18
18
|
defaults: {
|
package/server/config/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import queue from './queue.js';
|
|
|
18
18
|
import jobs from './jobs.js';
|
|
19
19
|
import scheduler from './scheduler.js';
|
|
20
20
|
import caching from './caching.js';
|
|
21
|
+
import realtime from './realtime.js';
|
|
21
22
|
import i18n from './i18n.js';
|
|
22
23
|
import app from './app.js';
|
|
23
24
|
import api from './api.js';
|
|
@@ -43,6 +44,7 @@ const defaultConfigs: AppConfigFactory<{
|
|
|
43
44
|
jobs: ReturnType<typeof jobs>;
|
|
44
45
|
scheduler: ReturnType<typeof scheduler>;
|
|
45
46
|
caching: ReturnType<typeof caching>;
|
|
47
|
+
realtime: ReturnType<typeof realtime>;
|
|
46
48
|
i18n: ReturnType<typeof i18n>;
|
|
47
49
|
app: ReturnType<typeof app>;
|
|
48
50
|
api: ReturnType<typeof api>;
|
|
@@ -67,6 +69,7 @@ const defaultConfigs: AppConfigFactory<{
|
|
|
67
69
|
jobs,
|
|
68
70
|
scheduler,
|
|
69
71
|
caching,
|
|
72
|
+
realtime,
|
|
70
73
|
i18n,
|
|
71
74
|
app,
|
|
72
75
|
api,
|
package/server/config/jobs.ts
CHANGED
|
@@ -2,14 +2,22 @@ import {
|
|
|
2
2
|
defineAppConfig,
|
|
3
3
|
type AppConfigFactory,
|
|
4
4
|
} from '@nocobase/app-server/config';
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
validateAppJobsConfig,
|
|
7
|
+
type AppJobsConfig,
|
|
8
|
+
} from '@nocobase/app-server/jobs';
|
|
6
9
|
|
|
7
|
-
/** Single-process persistence.
|
|
8
|
-
const jobs: AppConfigFactory<AppJobsConfig> = defineAppConfig(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
10
|
+
/** Single-process persistence. Without default, the built-in local configuration remains selected. */
|
|
11
|
+
const jobs: AppConfigFactory<AppJobsConfig> = defineAppConfig({
|
|
12
|
+
defaults: ({ paths }) => ({
|
|
13
|
+
backends: {
|
|
14
|
+
memory: {
|
|
15
|
+
adapter: 'memory',
|
|
16
|
+
persistence: { path: paths.storage('jobs') },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
validate: validateAppJobsConfig,
|
|
21
|
+
});
|
|
14
22
|
|
|
15
23
|
export default jobs;
|
package/server/config/queue.ts
CHANGED
|
@@ -2,21 +2,22 @@ import {
|
|
|
2
2
|
defineAppConfig,
|
|
3
3
|
type AppConfigFactory,
|
|
4
4
|
} from '@nocobase/app-server/config';
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
validateAppQueueConfig,
|
|
7
|
+
type AppQueueConfig,
|
|
8
|
+
} from '@nocobase/app-server/queue';
|
|
6
9
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
memory: {
|
|
16
|
-
adapter: 'inMemory',
|
|
17
|
-
persistence: { path: paths.storage('queue') },
|
|
10
|
+
/** Single-process persistence. Without default, the built-in local configuration remains selected. */
|
|
11
|
+
const queue: AppConfigFactory<AppQueueConfig> = defineAppConfig({
|
|
12
|
+
defaults: ({ paths }) => ({
|
|
13
|
+
backends: {
|
|
14
|
+
memory: {
|
|
15
|
+
adapter: 'inMemory',
|
|
16
|
+
persistence: { path: paths.storage('queue') },
|
|
17
|
+
},
|
|
18
18
|
},
|
|
19
19
|
}),
|
|
20
|
-
|
|
20
|
+
validate: validateAppQueueConfig,
|
|
21
|
+
});
|
|
21
22
|
|
|
22
23
|
export default queue;
|
|
@@ -149,6 +149,6 @@ describe('authentication translations', () => {
|
|
|
149
149
|
await act(() => value.changeLanguage('zh-CN'));
|
|
150
150
|
expect(screen.getByText('此密码重置链接无效或已过期。')).toBeVisible();
|
|
151
151
|
expect(screen.getByText('如果账户存在,重置链接已发送。')).toBeVisible();
|
|
152
|
-
expect(screen.
|
|
152
|
+
expect(await screen.findByRole('status', { name: '加载中' })).toBeVisible();
|
|
153
153
|
});
|
|
154
154
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { expect, it } from 'vitest';
|
|
2
|
+
import { createAuthenticatedClientRenderer } from '../helpers/authenticated-client.js';
|
|
3
|
+
|
|
4
|
+
const render = createAuthenticatedClientRenderer();
|
|
5
|
+
const identities = new Set<string>();
|
|
6
|
+
const permissionSets = new Set<string>();
|
|
7
|
+
const resource = { type: 'settings', id: 'authorization.default-access' };
|
|
8
|
+
|
|
9
|
+
it.each([
|
|
10
|
+
{ name: 'administrator', member: false, granted: false },
|
|
11
|
+
{ name: 'permitted member', member: true, granted: true },
|
|
12
|
+
{ name: 'unpermitted member', member: true, granted: false },
|
|
13
|
+
])(
|
|
14
|
+
'isolates the $name session and grants in a reused application',
|
|
15
|
+
async ({ member, granted }) => {
|
|
16
|
+
const view = await render(<span>Isolated client</span>, {
|
|
17
|
+
member,
|
|
18
|
+
grants: granted ? [{ resource, actions: [{ action: 'read' }] }] : [],
|
|
19
|
+
});
|
|
20
|
+
expect(identities.has(view.session.user.id)).toBe(false);
|
|
21
|
+
expect(permissionSets.has(view.permissionSetKey)).toBe(false);
|
|
22
|
+
identities.add(view.session.user.id);
|
|
23
|
+
permissionSets.add(view.permissionSetKey);
|
|
24
|
+
expect(view.identity().session?.user.id).toBe(view.session.user.id);
|
|
25
|
+
|
|
26
|
+
const response = await view.session.fetch('/authorization/permissions');
|
|
27
|
+
expect(response.status).toBe(200);
|
|
28
|
+
const { data } = await response.json();
|
|
29
|
+
expect(data.unrestricted).toBe(!member);
|
|
30
|
+
const permission = expect.objectContaining({
|
|
31
|
+
resource,
|
|
32
|
+
actions: expect.arrayContaining(['read']),
|
|
33
|
+
});
|
|
34
|
+
if (granted) expect(data.permissions).toContainEqual(permission);
|
|
35
|
+
else expect(data.permissions).not.toContainEqual(permission);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
@@ -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,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} />,
|
|
@@ -186,16 +186,17 @@ describe('app client routes', () => {
|
|
|
186
186
|
{ name: 'home', authorizedAs: null },
|
|
187
187
|
{ name: 'inbox', authorizedAs: null },
|
|
188
188
|
{ name: 'settingsHome', authorizedAs: null },
|
|
189
|
+
{ name: 'databaseExplorer', authorizedAs: 'database-explorer' },
|
|
189
190
|
]);
|
|
190
191
|
});
|
|
191
192
|
|
|
192
|
-
it('
|
|
193
|
-
//
|
|
193
|
+
it('includes database browsing and supports business settings registration', async () => {
|
|
194
|
+
// The built-in database browser joins the settings home without enabling business administration.
|
|
194
195
|
expect(
|
|
195
196
|
pagePaths(resolveRoutes().routes).filter((path) =>
|
|
196
197
|
path.startsWith('/settings'),
|
|
197
198
|
),
|
|
198
|
-
).toEqual(['/settings']);
|
|
199
|
+
).toEqual(['/settings', '/settings/database-explorer']);
|
|
199
200
|
const built = resolveAppClientContributions([
|
|
200
201
|
{
|
|
201
202
|
packageName: '@nocobase/app-template-default',
|
|
@@ -231,6 +232,7 @@ describe('app client routes', () => {
|
|
|
231
232
|
name,
|
|
232
233
|
authorizedAs: 'settings:authorization.default-access',
|
|
233
234
|
})),
|
|
235
|
+
{ name: 'databaseExplorer', authorizedAs: 'database-explorer' },
|
|
234
236
|
]);
|
|
235
237
|
for (const loader of componentLoadersIn(built.routes))
|
|
236
238
|
await expect(loader()).resolves.toMatchObject({
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { expect, it } from 'vitest';
|
|
4
|
+
import DatabaseExplorerPage from '../../client/pages/settings/database-explorer/index.js';
|
|
5
|
+
import { createAuthenticatedClientRenderer } from '../helpers/authenticated-client.js';
|
|
6
|
+
|
|
7
|
+
const renderAuthenticated = createAuthenticatedClientRenderer();
|
|
8
|
+
|
|
9
|
+
it('browses real database tables and fields as an administrator', async () => {
|
|
10
|
+
await renderAuthenticated(<DatabaseExplorerPage />, {
|
|
11
|
+
route: '/settings/database-explorer',
|
|
12
|
+
});
|
|
13
|
+
const user = userEvent.setup();
|
|
14
|
+
const tables = await screen.findAllByRole('button', { pressed: false });
|
|
15
|
+
expect(tables.length).toBeGreaterThan(0);
|
|
16
|
+
await user.click(tables[0]);
|
|
17
|
+
expect(await screen.findByRole('table')).toBeInTheDocument();
|
|
18
|
+
expect(screen.getAllByRole('cell').length).toBeGreaterThan(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('keeps database information inaccessible to an ordinary member', async () => {
|
|
22
|
+
await renderAuthenticated(<DatabaseExplorerPage />, {
|
|
23
|
+
route: '/settings/database-explorer',
|
|
24
|
+
member: true,
|
|
25
|
+
});
|
|
26
|
+
expect(await screen.findByRole('alert')).toHaveTextContent(
|
|
27
|
+
'You do not have permission',
|
|
28
|
+
);
|
|
29
|
+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
|
30
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
answerApi,
|
|
3
|
+
renderWithApp,
|
|
4
|
+
type ApiCall,
|
|
5
|
+
} from '@nocobase/app-testing/client';
|
|
6
|
+
import { act, screen, within } from '@testing-library/react';
|
|
7
|
+
import userEvent from '@testing-library/user-event';
|
|
8
|
+
import { expect, it } from 'vitest';
|
|
9
|
+
import DatabaseExplorerPage from '../../client/pages/settings/database-explorer/index.js';
|
|
10
|
+
import enUS from '../../client/locales/en-US.js';
|
|
11
|
+
import packageMetadata from '../../package.json' with { type: 'json' };
|
|
12
|
+
|
|
13
|
+
const table = (name: string) => ({
|
|
14
|
+
name,
|
|
15
|
+
tableName: name,
|
|
16
|
+
schema: '',
|
|
17
|
+
kind: 'table',
|
|
18
|
+
});
|
|
19
|
+
const detail = (name: string) => ({
|
|
20
|
+
data: {
|
|
21
|
+
collection: {
|
|
22
|
+
name,
|
|
23
|
+
collection: { fields: [{ name, type: 'string' }] },
|
|
24
|
+
warnings: [],
|
|
25
|
+
},
|
|
26
|
+
metadata: null,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const connections = {
|
|
30
|
+
data: [{ name: 'main', isDefault: true, dialect: 'postgres' }],
|
|
31
|
+
};
|
|
32
|
+
function mount(api: (call: ApiCall) => unknown) {
|
|
33
|
+
return renderWithApp(<DatabaseExplorerPage />, {
|
|
34
|
+
route: '/settings/database-explorer',
|
|
35
|
+
namespace: packageMetadata.name,
|
|
36
|
+
namespaces: { [packageMetadata.name]: enUS },
|
|
37
|
+
fetch: answerApi(api),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
it('searches all pages of tables and displays the selected table fields', async () => {
|
|
42
|
+
const user = userEvent.setup();
|
|
43
|
+
let pages = 0;
|
|
44
|
+
await mount(({ path }) => {
|
|
45
|
+
if (path.endsWith('/connections')) return connections;
|
|
46
|
+
if (path.endsWith('/collections'))
|
|
47
|
+
return ++pages === 1
|
|
48
|
+
? { data: [table('orders')], meta: { nextPageToken: 'next' } }
|
|
49
|
+
: { data: [table('customers')] };
|
|
50
|
+
if (path.endsWith('/collections/customers')) return detail('customer_name');
|
|
51
|
+
throw new Error(`Unexpected request: ${path}`);
|
|
52
|
+
});
|
|
53
|
+
await screen.findByRole('button', { name: 'customers' });
|
|
54
|
+
await user.type(
|
|
55
|
+
screen.getByRole('textbox', { name: 'Search tables' }),
|
|
56
|
+
'customers',
|
|
57
|
+
);
|
|
58
|
+
expect(
|
|
59
|
+
screen.queryByRole('button', { name: 'orders' }),
|
|
60
|
+
).not.toBeInTheDocument();
|
|
61
|
+
await user.click(screen.getByRole('button', { name: 'customers' }));
|
|
62
|
+
expect(
|
|
63
|
+
await screen.findByRole('cell', { name: 'customer_name' }),
|
|
64
|
+
).toBeInTheDocument();
|
|
65
|
+
await user.type(
|
|
66
|
+
screen.getByRole('textbox', { name: 'Search fields' }),
|
|
67
|
+
'missing',
|
|
68
|
+
);
|
|
69
|
+
expect(screen.getByText('No fields found.')).toBeInTheDocument();
|
|
70
|
+
expect(pages).toBe(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not display a late response for a previously selected table', async () => {
|
|
74
|
+
const user = userEvent.setup();
|
|
75
|
+
const slow = Promise.withResolvers<unknown>();
|
|
76
|
+
await mount(({ path }) => {
|
|
77
|
+
if (path.endsWith('/connections')) return connections;
|
|
78
|
+
if (path.endsWith('/collections'))
|
|
79
|
+
return { data: [table('orders'), table('customers')] };
|
|
80
|
+
if (path.endsWith('/orders')) return slow.promise;
|
|
81
|
+
if (path.endsWith('/customers')) return detail('customer_name');
|
|
82
|
+
throw new Error(`Unexpected request: ${path}`);
|
|
83
|
+
});
|
|
84
|
+
await user.click(await screen.findByRole('button', { name: 'orders' }));
|
|
85
|
+
expect(
|
|
86
|
+
within(screen.getByRole('region', { name: 'Fields' })).getByRole('status'),
|
|
87
|
+
).toHaveTextContent('Loading');
|
|
88
|
+
await user.click(screen.getByRole('button', { name: 'customers' }));
|
|
89
|
+
await screen.findByRole('cell', { name: 'customer_name' });
|
|
90
|
+
await act(async () => slow.resolve(detail('order_name')));
|
|
91
|
+
expect(
|
|
92
|
+
screen.queryByRole('cell', { name: 'order_name' }),
|
|
93
|
+
).not.toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('offers retry for a failed connection listing and handles an empty listing', async () => {
|
|
97
|
+
let attempts = 0;
|
|
98
|
+
const user = userEvent.setup();
|
|
99
|
+
await mount(() =>
|
|
100
|
+
++attempts === 1 ? new Response(null, { status: 503 }) : { data: [] },
|
|
101
|
+
);
|
|
102
|
+
expect(await screen.findByRole('alert')).toHaveTextContent(
|
|
103
|
+
'Unable to load database information.',
|
|
104
|
+
);
|
|
105
|
+
await user.click(screen.getByRole('button', { name: 'Retry' }));
|
|
106
|
+
expect(
|
|
107
|
+
await screen.findByText('No database connections are configured.'),
|
|
108
|
+
).toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('explains forbidden access without offering a retry', async () => {
|
|
112
|
+
await mount(() => new Response(null, { status: 403 }));
|
|
113
|
+
expect(await screen.findByRole('alert')).toHaveTextContent(
|
|
114
|
+
'You do not have permission',
|
|
115
|
+
);
|
|
116
|
+
expect(
|
|
117
|
+
screen.queryByRole('button', { name: 'Retry' }),
|
|
118
|
+
).not.toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('clears the selected table when switching connections', async () => {
|
|
122
|
+
const user = userEvent.setup();
|
|
123
|
+
await mount(({ path }) => {
|
|
124
|
+
if (path.endsWith('/connections'))
|
|
125
|
+
return {
|
|
126
|
+
data: [
|
|
127
|
+
...connections.data,
|
|
128
|
+
{ name: 'external', isDefault: false, dialect: 'postgres' },
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
if (path.endsWith('main/collections')) return { data: [table('orders')] };
|
|
132
|
+
if (path.endsWith('external/collections'))
|
|
133
|
+
return { data: [table('contacts')] };
|
|
134
|
+
if (path.endsWith('/orders')) return detail('order_name');
|
|
135
|
+
throw new Error(`Unexpected request: ${path}`);
|
|
136
|
+
});
|
|
137
|
+
await user.click(await screen.findByRole('button', { name: 'orders' }));
|
|
138
|
+
await screen.findByRole('cell', { name: 'order_name' });
|
|
139
|
+
await user.click(
|
|
140
|
+
screen.getByRole('combobox', { name: 'Database connection' }),
|
|
141
|
+
);
|
|
142
|
+
await user.click(await screen.findByRole('option', { name: /external/ }));
|
|
143
|
+
expect(
|
|
144
|
+
await screen.findByRole('button', { name: 'contacts' }),
|
|
145
|
+
).toBeInTheDocument();
|
|
146
|
+
expect(
|
|
147
|
+
screen.queryByRole('cell', { name: 'order_name' }),
|
|
148
|
+
).not.toBeInTheDocument();
|
|
149
|
+
expect(
|
|
150
|
+
screen.getByText('Select a table to view its fields.'),
|
|
151
|
+
).toBeInTheDocument();
|
|
152
|
+
});
|