@nocobase/app-template-default 1.0.0-beta.89 → 1.0.0-beta.91
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/shell.md +4 -0
- package/LICENSE.txt +129 -0
- package/client/lib/navigation-transform.ts +29 -1
- package/client/locales/system/en-US.ts +4 -0
- package/client/locales/system/zh-CN.ts +4 -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/plugins.ts +8 -1
- package/client/routes.ts +2 -1
- package/config.example.yml +17 -11
- package/index.html +8 -11
- package/package.json +26 -26
- 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/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 +71 -29
- package/tests/client/home.test.tsx +26 -85
- package/tests/client/layout-i18n.test.tsx +83 -2
- package/tests/client/settings-navigation.test.ts +20 -0
- package/tests/helpers/authenticated-client.tsx +7 -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/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;
|
|
@@ -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
|
+
});
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createCapabilityCatalog,
|
|
4
|
+
entryContent,
|
|
5
|
+
guideContent,
|
|
6
|
+
type DependencyManifest,
|
|
7
|
+
} from '@nocobase/app-plugin-foundation/capability-guide';
|
|
8
|
+
import packageMetadata from '../../package.json' with { type: 'json' };
|
|
2
9
|
import { resolveAppRuntime } from '@nocobase/app-client/runtime';
|
|
3
10
|
import { defineAppRuntime } from '@nocobase/app-client/runtime';
|
|
4
11
|
import appRuntime from '../../client/runtime.js';
|
|
5
12
|
import { createApplicationRoutes } from '../../client/routes.js';
|
|
6
13
|
import cliPlugins from '../../cli/plugins.js';
|
|
7
|
-
import serverPlugins from '../../server/plugins.js';
|
|
8
14
|
import settingsRoutes from '../../client/pages/settings/routes.js';
|
|
9
15
|
import {
|
|
16
|
+
routeKey,
|
|
10
17
|
buildRouteNavigation,
|
|
11
18
|
buildSettingsCatalog,
|
|
12
19
|
} from '../../client/routing/route-navigation.js';
|
|
@@ -19,38 +26,21 @@ describe('default Foundation integration', () => {
|
|
|
19
26
|
),
|
|
20
27
|
).toHaveLength(1);
|
|
21
28
|
});
|
|
22
|
-
it('
|
|
29
|
+
it('provides Features and extensions in System settings with its existing route', async () => {
|
|
23
30
|
const runtime = await resolveAppRuntime(appRuntime);
|
|
24
|
-
const menu = buildRouteNavigation(runtime.routes, new Set());
|
|
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
31
|
const category = buildSettingsCatalog(runtime.routes, new Set()).find(
|
|
31
32
|
({ route }) => route.name === 'settingsSystem',
|
|
32
33
|
);
|
|
33
|
-
const
|
|
34
|
-
({ route }) => route.name === '
|
|
35
|
-
)
|
|
36
|
-
expect(
|
|
37
|
-
|
|
38
|
-
auth: 'required',
|
|
39
|
-
authz: 'unrestricted',
|
|
40
|
-
});
|
|
41
|
-
if (!page) throw new Error('Foundation route missing');
|
|
42
|
-
const denied = new Set([`${page.packageName}:${page.path}:${page.id}`]);
|
|
43
|
-
expect(
|
|
44
|
-
buildRouteNavigation(runtime.routes, denied).map(
|
|
45
|
-
({ route }) => route.name,
|
|
46
|
-
),
|
|
47
|
-
).toEqual(['home']);
|
|
48
|
-
expect(buildSettingsCatalog(runtime.routes, denied)).toEqual([]);
|
|
34
|
+
const guide = category?.modules.find(
|
|
35
|
+
({ route }) => route.name === 'capabilityGuide',
|
|
36
|
+
);
|
|
37
|
+
expect(guide?.target.path).toBe('/capabilities');
|
|
38
|
+
expect(guide?.route.auth).toBe('required');
|
|
49
39
|
expect(
|
|
50
|
-
|
|
51
|
-
(
|
|
52
|
-
|
|
53
|
-
).
|
|
40
|
+
buildSettingsCatalog(runtime.routes, new Set([routeKey(guide!.route)]))
|
|
41
|
+
.flatMap((item) => item.modules)
|
|
42
|
+
.some((item) => item.route.name === 'capabilityGuide'),
|
|
43
|
+
).toBe(false);
|
|
54
44
|
});
|
|
55
45
|
|
|
56
46
|
it('keeps Foundation beside business settings without changing its protected URL', async () => {
|
|
@@ -77,7 +67,59 @@ describe('default Foundation integration', () => {
|
|
|
77
67
|
['permissionManagement', '/settings/roles'],
|
|
78
68
|
],
|
|
79
69
|
],
|
|
80
|
-
['settingsSystem', [['
|
|
70
|
+
['settingsSystem', [['capabilityGuide', '/capabilities']]],
|
|
81
71
|
]);
|
|
82
72
|
});
|
|
73
|
+
|
|
74
|
+
it('lists every plugin this application declares in the capability guide, with the standalone core modules', () => {
|
|
75
|
+
const manifest = packageMetadata as DependencyManifest;
|
|
76
|
+
const { entries } = createCapabilityCatalog(manifest);
|
|
77
|
+
const declared = Object.keys({
|
|
78
|
+
...manifest.dependencies,
|
|
79
|
+
...manifest.devDependencies,
|
|
80
|
+
})
|
|
81
|
+
.filter((name) => name.includes('app-plugin-'))
|
|
82
|
+
.sort();
|
|
83
|
+
expect(
|
|
84
|
+
entries
|
|
85
|
+
.filter((entry) => entry.source === 'plugin')
|
|
86
|
+
.map((entry) => entry.packageName)
|
|
87
|
+
.sort(),
|
|
88
|
+
).toEqual(declared);
|
|
89
|
+
expect(
|
|
90
|
+
entries
|
|
91
|
+
.filter((entry) => entry.source === 'kernel')
|
|
92
|
+
.map((entry) => entry.name),
|
|
93
|
+
).toEqual(['db', 'jobs', 'lifecycle', 'queue', 'realtime']);
|
|
94
|
+
// Installed extensions may use the guide's generic introduction.
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
for (const locale of ['en-US', 'zh-CN']) {
|
|
97
|
+
const text = entryContent(guideContent(locale), entry);
|
|
98
|
+
expect(text.title.trim()).not.toBe('');
|
|
99
|
+
expect(text.summary.trim()).not.toBe('');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('keeps an additional plugin discoverable without built-in wording', () => {
|
|
105
|
+
const packageName = '@example/app-plugin-business-extension';
|
|
106
|
+
const catalog = createCapabilityCatalog({
|
|
107
|
+
...packageMetadata,
|
|
108
|
+
dependencies: {
|
|
109
|
+
...packageMetadata.dependencies,
|
|
110
|
+
[packageName]: '1.0.0',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const entry = catalog.entries.find(
|
|
114
|
+
(item) => item.packageName === packageName,
|
|
115
|
+
)!;
|
|
116
|
+
expect(entry).toMatchObject({ source: 'plugin', known: false });
|
|
117
|
+
expect(catalog.find(entry.id)).toBe(entry);
|
|
118
|
+
for (const locale of ['en-US', 'zh-CN']) {
|
|
119
|
+
const text = entryContent(guideContent(locale), entry);
|
|
120
|
+
expect(text.title).toContain(packageName);
|
|
121
|
+
expect(text.summary.trim()).not.toBe('');
|
|
122
|
+
expect(text.prompts.main?.template).toContain(packageName);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
83
125
|
});
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { renderWithApp } from '@nocobase/app-testing/client';
|
|
2
|
-
import { screen
|
|
2
|
+
import { screen } from '@testing-library/react';
|
|
3
3
|
import userEvent from '@testing-library/user-event';
|
|
4
4
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
|
|
6
6
|
import packageMetadata from '../../package.json' with { type: 'json' };
|
|
7
7
|
import enUS from '../../client/locales/en-US.js';
|
|
8
|
-
import zhCN from '../../client/locales/zh-CN.js';
|
|
9
8
|
import HomePage from '../../client/pages/home/index.js';
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
CAPABILITY_GUIDE_PATH,
|
|
11
|
+
capabilityPath,
|
|
12
|
+
createCapabilityCatalog,
|
|
13
|
+
entryContent,
|
|
14
|
+
format,
|
|
15
|
+
guideContent,
|
|
16
|
+
} from '@nocobase/app-plugin-foundation/capability-guide';
|
|
17
|
+
|
|
18
|
+
const catalog = createCapabilityCatalog(packageMetadata);
|
|
19
|
+
const escape = (text: string): string =>
|
|
20
|
+
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
11
21
|
|
|
12
22
|
function renderHome() {
|
|
13
23
|
return renderWithApp(<HomePage />, {
|
|
@@ -48,79 +58,28 @@ describe('home page', () => {
|
|
|
48
58
|
expect(screen.getByText(enUS.home.steps.describe.title)).toBeVisible();
|
|
49
59
|
});
|
|
50
60
|
|
|
51
|
-
it('
|
|
61
|
+
it('features capabilities from the shared guide catalog, each linking to its entry', async () => {
|
|
52
62
|
await renderHome();
|
|
63
|
+
const content = guideContent('en-US');
|
|
53
64
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
expect(catalog.featured.length).toBeGreaterThan(0);
|
|
66
|
+
for (const entry of catalog.featured) {
|
|
67
|
+
const title = entryContent(content, entry).title;
|
|
57
68
|
expect(
|
|
58
|
-
screen.getByRole('
|
|
59
|
-
).
|
|
69
|
+
screen.getByRole('link', { name: new RegExp(escape(title)) }),
|
|
70
|
+
).toHaveAttribute('href', capabilityPath(entry.id));
|
|
60
71
|
}
|
|
61
72
|
});
|
|
62
73
|
|
|
63
|
-
it('
|
|
64
|
-
const user = setup();
|
|
65
|
-
const view = await renderHome();
|
|
66
|
-
const scheduler = enUS.home.capabilities.scheduler;
|
|
67
|
-
|
|
68
|
-
await user.click(
|
|
69
|
-
screen.getByRole('button', { name: new RegExp(scheduler.title) }),
|
|
70
|
-
);
|
|
71
|
-
const dialog = await screen.findByRole('dialog');
|
|
72
|
-
const prompt = within(dialog).getByRole('textbox', {
|
|
73
|
-
name: enUS.home.prompt.label,
|
|
74
|
-
});
|
|
75
|
-
expect(prompt).toHaveValue(scheduler.prompts.reminder.text);
|
|
76
|
-
|
|
77
|
-
await user.click(
|
|
78
|
-
within(dialog).getByRole('tab', {
|
|
79
|
-
name: scheduler.prompts.weeklyReport.label,
|
|
80
|
-
}),
|
|
81
|
-
);
|
|
82
|
-
expect(prompt).toHaveValue(scheduler.prompts.weeklyReport.text);
|
|
83
|
-
|
|
84
|
-
await user.clear(prompt);
|
|
85
|
-
await user.type(prompt, 'Every Friday');
|
|
86
|
-
await user.click(
|
|
87
|
-
within(dialog).getByRole('button', { name: enUS.home.prompt.copy }),
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
expect(writeText).toHaveBeenCalledWith('Every Friday');
|
|
91
|
-
expect(view.toasts()).toEqual([
|
|
92
|
-
expect.objectContaining({
|
|
93
|
-
type: 'success',
|
|
94
|
-
title: enUS.home.prompt.copied,
|
|
95
|
-
}),
|
|
96
|
-
]);
|
|
97
|
-
|
|
98
|
-
await user.click(
|
|
99
|
-
within(dialog).getByRole('button', { name: enUS.home.prompt.reset }),
|
|
100
|
-
);
|
|
101
|
-
expect(prompt).toHaveValue(scheduler.prompts.weeklyReport.text);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('starts from the default prompt each time the dialog opens', async () => {
|
|
105
|
-
const user = setup();
|
|
74
|
+
it('leads to the full catalog, counted from the dependencies this application declares', async () => {
|
|
106
75
|
await renderHome();
|
|
107
|
-
const
|
|
108
|
-
const card = screen.getByRole('button', { name: new RegExp(theme.title) });
|
|
76
|
+
const content = guideContent('en-US');
|
|
109
77
|
|
|
110
|
-
await user.click(card);
|
|
111
|
-
const prompt = within(await screen.findByRole('dialog')).getByRole(
|
|
112
|
-
'textbox',
|
|
113
|
-
);
|
|
114
|
-
await user.type(prompt, ' Edited.');
|
|
115
|
-
await user.keyboard('{Escape}');
|
|
116
|
-
await waitFor(() =>
|
|
117
|
-
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
await user.click(card);
|
|
121
78
|
expect(
|
|
122
|
-
|
|
123
|
-
|
|
79
|
+
screen.getByRole('link', {
|
|
80
|
+
name: format(content.home.viewAll, { count: catalog.entries.length }),
|
|
81
|
+
}),
|
|
82
|
+
).toHaveAttribute('href', CAPABILITY_GUIDE_PATH);
|
|
124
83
|
});
|
|
125
84
|
|
|
126
85
|
it('reports a copy the browser refuses', async () => {
|
|
@@ -140,21 +99,3 @@ describe('home page', () => {
|
|
|
140
99
|
]);
|
|
141
100
|
});
|
|
142
101
|
});
|
|
143
|
-
|
|
144
|
-
describe('home page copy', () => {
|
|
145
|
-
it('translates every capability prompt into Chinese', () => {
|
|
146
|
-
for (const { id, prompts } of CAPABILITIES) {
|
|
147
|
-
const key = id as keyof typeof zhCN.home.capabilities;
|
|
148
|
-
for (const prompt of prompts) {
|
|
149
|
-
const zh = (
|
|
150
|
-
zhCN.home.capabilities[key].prompts as Record<
|
|
151
|
-
string,
|
|
152
|
-
{ label: string; text: string }
|
|
153
|
-
>
|
|
154
|
-
)[prompt];
|
|
155
|
-
expect(zh?.text, `${id}.${prompt}`).toBeTruthy();
|
|
156
|
-
expect(zh?.label, `${id}.${prompt}`).toBeTruthy();
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import foundation from '@nocobase/app-plugin-foundation/client/plugin';
|
|
1
2
|
import {
|
|
2
3
|
ClientApplicationContext,
|
|
3
4
|
NavigationGuardProvider,
|
|
@@ -7,10 +8,17 @@ import {
|
|
|
7
8
|
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
8
9
|
import { I18nRuntime } from '@nocobase/i18n';
|
|
9
10
|
import { I18nProvider } from '@nocobase/i18n/client';
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
act,
|
|
13
|
+
fireEvent,
|
|
14
|
+
render,
|
|
15
|
+
screen,
|
|
16
|
+
waitFor,
|
|
17
|
+
within,
|
|
18
|
+
} from '@testing-library/react';
|
|
11
19
|
import type { ReactNode } from 'react';
|
|
12
20
|
import { MemoryRouter } from 'react-router';
|
|
13
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
21
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
22
|
|
|
15
23
|
import locales from '../../client/locales/index.js';
|
|
16
24
|
import { AppLayout } from '../../client/layouts/app-layout.js';
|
|
@@ -37,6 +45,8 @@ vi.mock('../../client/routing/route-navigation.js', async (importOriginal) => ({
|
|
|
37
45
|
}),
|
|
38
46
|
}));
|
|
39
47
|
|
|
48
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
49
|
+
|
|
40
50
|
/** The page the shell opens on: the header names it. */
|
|
41
51
|
const overview: AppClientRegisteredRoute = {
|
|
42
52
|
id: 'overview',
|
|
@@ -62,6 +72,10 @@ async function setup(children: ReactNode) {
|
|
|
62
72
|
applicationNamespace: 'test-app',
|
|
63
73
|
});
|
|
64
74
|
runtime.registerApplicationNamespace('test-app', locales);
|
|
75
|
+
runtime.registerNamespace(
|
|
76
|
+
'@nocobase/app-plugin-foundation',
|
|
77
|
+
foundation().locales!,
|
|
78
|
+
);
|
|
65
79
|
await runtime.init('en-US');
|
|
66
80
|
const app = {
|
|
67
81
|
config: createAppClientConfig({ rawConfig: {} }),
|
|
@@ -110,3 +124,70 @@ describe('shell translations', () => {
|
|
|
110
124
|
).toBeVisible();
|
|
111
125
|
});
|
|
112
126
|
});
|
|
127
|
+
|
|
128
|
+
it('translates the settings navigation without a separate resources footer', async () => {
|
|
129
|
+
const settings: AppClientRegisteredRoute = {
|
|
130
|
+
...overview,
|
|
131
|
+
id: 'settings',
|
|
132
|
+
name: 'settings',
|
|
133
|
+
path: '/settings',
|
|
134
|
+
packageName: 'test',
|
|
135
|
+
source: 'application',
|
|
136
|
+
auth: 'required',
|
|
137
|
+
authz: 'skip',
|
|
138
|
+
navigation: { title: 'settings.title' },
|
|
139
|
+
};
|
|
140
|
+
const runtime = await setup(<AppLayout routes={[overview, settings]} />);
|
|
141
|
+
const navigation = screen.getByRole('navigation', {
|
|
142
|
+
name: 'Application navigation',
|
|
143
|
+
});
|
|
144
|
+
const link = within(navigation).getByRole('link', {
|
|
145
|
+
name: 'System settings',
|
|
146
|
+
});
|
|
147
|
+
expect(link).toHaveAttribute('href', '/settings');
|
|
148
|
+
expect(link.closest('[data-slot="sidebar-content"]')).not.toBeNull();
|
|
149
|
+
expect(
|
|
150
|
+
screen.queryByRole('navigation', { name: 'Resources' }),
|
|
151
|
+
).not.toBeInTheDocument();
|
|
152
|
+
fireEvent.click(link);
|
|
153
|
+
await waitFor(() =>
|
|
154
|
+
expect(
|
|
155
|
+
within(navigation).getByRole('link', { name: 'System settings' }),
|
|
156
|
+
).toHaveAttribute('aria-current', 'page'),
|
|
157
|
+
);
|
|
158
|
+
await act(() => runtime.changeLanguage('zh-CN'));
|
|
159
|
+
expect(
|
|
160
|
+
within(screen.getByRole('navigation', { name: '应用导航' })).getByRole(
|
|
161
|
+
'link',
|
|
162
|
+
{ name: '系统设置' },
|
|
163
|
+
),
|
|
164
|
+
).toHaveAttribute('href', '/settings');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('closes the mobile sidebar after opening System settings', async () => {
|
|
168
|
+
vi.stubGlobal('innerWidth', 375);
|
|
169
|
+
const settings: AppClientRegisteredRoute = {
|
|
170
|
+
...overview,
|
|
171
|
+
id: 'settings',
|
|
172
|
+
name: 'settings',
|
|
173
|
+
path: '/settings',
|
|
174
|
+
packageName: 'test',
|
|
175
|
+
source: 'application',
|
|
176
|
+
auth: 'required',
|
|
177
|
+
authz: 'skip',
|
|
178
|
+
navigation: { title: 'settings.title' },
|
|
179
|
+
};
|
|
180
|
+
await setup(<AppLayout routes={[overview, settings]} />);
|
|
181
|
+
fireEvent.click(screen.getByRole('button', { name: 'Open navigation' }));
|
|
182
|
+
const navigation = await screen.findByRole('navigation', {
|
|
183
|
+
name: 'Application navigation',
|
|
184
|
+
});
|
|
185
|
+
fireEvent.click(
|
|
186
|
+
within(navigation).getByRole('link', { name: 'System settings' }),
|
|
187
|
+
);
|
|
188
|
+
await waitFor(() =>
|
|
189
|
+
expect(
|
|
190
|
+
screen.queryByRole('navigation', { name: 'Application navigation' }),
|
|
191
|
+
).not.toBeInTheDocument(),
|
|
192
|
+
);
|
|
193
|
+
});
|