@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.
Files changed (38) hide show
  1. package/.agents/rules/shell.md +4 -0
  2. package/LICENSE.txt +129 -0
  3. package/client/lib/navigation-transform.ts +29 -1
  4. package/client/locales/system/en-US.ts +4 -0
  5. package/client/locales/system/zh-CN.ts +4 -0
  6. package/client/pages/home/index.tsx +66 -51
  7. package/client/pages/settings/database-explorer/en-US.ts +33 -0
  8. package/client/pages/settings/database-explorer/index.tsx +358 -0
  9. package/client/pages/settings/database-explorer/routes.ts +14 -0
  10. package/client/pages/settings/database-explorer/zh-CN.ts +30 -0
  11. package/client/plugins.ts +8 -1
  12. package/client/routes.ts +2 -1
  13. package/config.example.yml +17 -11
  14. package/index.html +8 -11
  15. package/package.json +26 -26
  16. package/server/config/auth.ts +1 -1
  17. package/server/config/index.ts +3 -0
  18. package/server/config/jobs.ts +16 -8
  19. package/server/config/queue.ts +14 -13
  20. package/server/config/realtime.ts +4 -0
  21. package/tests/client/client-routes.test.ts +5 -3
  22. package/tests/client/database-explorer-integration.test.tsx +30 -0
  23. package/tests/client/database-explorer.test.tsx +152 -0
  24. package/tests/client/foundation-integration.test.ts +71 -29
  25. package/tests/client/home.test.tsx +26 -85
  26. package/tests/client/layout-i18n.test.tsx +83 -2
  27. package/tests/client/settings-navigation.test.ts +20 -0
  28. package/tests/helpers/authenticated-client.tsx +7 -0
  29. package/tests/project/bootstrap-loading.test.ts +60 -0
  30. package/tests/server/app-server.test.ts +18 -12
  31. package/tests/server/auth-email-config.test.ts +21 -0
  32. package/tests/server/backends-config.test.ts +118 -0
  33. package/tests/server/concurrency.test.ts +1 -1
  34. package/tests/server/config.test.ts +22 -14
  35. package/tests/server/lifecycle.test.ts +2 -2
  36. package/tests/server/realtime-config.test.ts +114 -0
  37. package/client/pages/home/capabilities.ts +0 -62
  38. package/client/pages/home/prompt-dialog.tsx +0 -122
@@ -228,3 +228,23 @@ describe('System settings navigation', () => {
228
228
  ).toEqual(['home', 'settingsHome']);
229
229
  });
230
230
  });
231
+
232
+ it('keeps database browsing accessible by route without adding a System settings card', () => {
233
+ const routes = resolveAppClientContributions([
234
+ {
235
+ packageName: app,
236
+ source: 'application',
237
+ routes: createApplicationRoutes({}),
238
+ },
239
+ ]).routes;
240
+ const page = find(routes, 'databaseExplorer');
241
+ expect(page.path).toBe('/settings/database-explorer');
242
+ expect(page.auth).toBe('required');
243
+ expect(page.authz).toEqual({
244
+ resource: { type: 'page', id: 'database-explorer' },
245
+ action: 'access',
246
+ });
247
+ expect(page.navigation).toBeUndefined();
248
+ expect(catalog(routes)).toEqual([]);
249
+ expect(catalog(routes, new Set([routeKey(page)]))).toEqual([]);
250
+ });
@@ -89,6 +89,13 @@ export async function renderAuthenticatedClient(
89
89
  function createClientTestServer() {
90
90
  return createTestApp({
91
91
  createServer: createStandaloneServer,
92
+ // Local preview origins must not change test cookie security or trusted origins.
93
+ server: {
94
+ env: {
95
+ APP_PUBLIC_ORIGIN: 'http://localhost',
96
+ BETTER_AUTH_TRUSTED_ORIGINS: 'http://localhost',
97
+ },
98
+ },
92
99
  connections: ['main'],
93
100
  config: {
94
101
  logging: { enabled: false },
@@ -0,0 +1,60 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const html = readFileSync(
6
+ fileURLToPath(new URL('../../index.html', import.meta.url)),
7
+ 'utf8',
8
+ );
9
+
10
+ /** The markup the browser paints before the client script has started and the visitor's language is known. */
11
+ function bootstrapRoot(): string {
12
+ const match = /<div id="root">([\s\S]*?)<\/div>\s*<script/u.exec(html);
13
+ if (!match) throw new Error('index.html has no #root placeholder.');
14
+ return match[1].replace(/<!--[\s\S]*?-->/gu, '');
15
+ }
16
+
17
+ /** The opening tag of the placeholder element, which carries its accessible semantics. */
18
+ function placeholderTag(): string {
19
+ const tag = /<div\s+class="app-bootstrap-loading"[^>]*>/u.exec(
20
+ bootstrapRoot(),
21
+ );
22
+ if (!tag) throw new Error('index.html has no bootstrap loading placeholder.');
23
+ return tag[0].replace(/\s+/gu, ' ');
24
+ }
25
+
26
+ describe('bootstrap loading in index.html', () => {
27
+ it('shows no text in any language before the client starts', () => {
28
+ const text = bootstrapRoot()
29
+ .replace(/<[^>]*>/gu, '')
30
+ .trim();
31
+
32
+ expect(text).toBe('');
33
+ });
34
+
35
+ it('is perceivable as loading without words in a language', () => {
36
+ const tag = placeholderTag();
37
+ const title = /<title>([^<]*)<\/title>/u.exec(html)?.[1];
38
+
39
+ // An indeterminate progress bar: the screen reader announces the role in its own language.
40
+ expect(tag).toContain('role="progressbar"');
41
+ expect(tag).not.toMatch(/aria-valuenow|aria-valuetext/u);
42
+ // Its name is the brand, which reads the same in every language and matches the document title.
43
+ expect(title).toBeTruthy();
44
+ expect(tag).toContain(`aria-label="${title}"`);
45
+ expect(bootstrapRoot()).toMatch(/<svg[^>]*aria-hidden="true"/u);
46
+ });
47
+
48
+ it('leaves announcing the loading to the localized status of the client', () => {
49
+ // A live region here would be announced, then announced again by the client's localized status.
50
+ expect(bootstrapRoot()).not.toMatch(
51
+ /role="status"|role="alert"|aria-live/u,
52
+ );
53
+ });
54
+
55
+ it('keeps the brand mark still for reduced motion', () => {
56
+ expect(html).toMatch(
57
+ /prefers-reduced-motion: reduce\)\s*\{\s*\.app-bootstrap-loading \.nocobase-loading-orbit\s*\{\s*animation: none;/u,
58
+ );
59
+ });
60
+ });
@@ -1170,12 +1170,14 @@ interface CreateTestAppOptions {
1170
1170
  function createTestQueueConfig(): AppQueueConfig {
1171
1171
  return {
1172
1172
  default: 'memory',
1173
- memory: {
1174
- adapter: 'inMemory',
1175
- persistence: {
1176
- path: mkdtempSync(
1177
- path.join(tmpdir(), 'nocobase-app-template-default-queue-'),
1178
- ),
1173
+ backends: {
1174
+ memory: {
1175
+ adapter: 'inMemory',
1176
+ persistence: {
1177
+ path: mkdtempSync(
1178
+ path.join(tmpdir(), 'nocobase-app-template-default-queue-'),
1179
+ ),
1180
+ },
1179
1181
  },
1180
1182
  },
1181
1183
  };
@@ -1537,17 +1539,21 @@ async function writeRuntimeTestConfig(
1537
1539
  // another suite may be using at the same time.
1538
1540
  jobs: {
1539
1541
  default: 'memory',
1540
- memory: {
1541
- adapter: 'memory',
1542
- persistence: { path: path.join(directory, 'jobs') },
1542
+ backends: {
1543
+ memory: {
1544
+ adapter: 'memory',
1545
+ persistence: { path: path.join(directory, 'jobs') },
1546
+ },
1543
1547
  },
1544
1548
  },
1545
1549
  // Queue state files likewise stay beside the test database.
1546
1550
  queue: {
1547
1551
  default: 'memory',
1548
- memory: {
1549
- adapter: 'inMemory',
1550
- persistence: { path: path.join(directory, 'queue') },
1552
+ backends: {
1553
+ memory: {
1554
+ adapter: 'inMemory',
1555
+ persistence: { path: path.join(directory, 'queue') },
1556
+ },
1551
1557
  },
1552
1558
  },
1553
1559
  database: {
@@ -111,3 +111,24 @@ it('accepts an entirely environment-configured shared sender without enabling re
111
111
  },
112
112
  });
113
113
  });
114
+
115
+ it('loads the direct password-reset reference from the inherited environment mapping', async () => {
116
+ const config = new AppConfig();
117
+ await config.loadAll();
118
+ const defaults = defaultAppConfigs({ auth, mailers });
119
+ config.mergeDefaults(defaults({} as never));
120
+ config.defineSections(defaults.sections!);
121
+ await config.loadSectionEnvironment({
122
+ AUTH_PASSWORD_RESET_MAILER: 'system',
123
+ MAILERS_SYSTEM_PROVIDER: 'smtp',
124
+ MAILERS_SYSTEM_HOST: 'localhost',
125
+ MAILERS_SYSTEM_PORT: '2525',
126
+ });
127
+ expect(await config.validate()).toEqual([]);
128
+ expect(config.get('auth.passwordReset.mailer')).toBe('system');
129
+ expect(config.get('auth.passwordReset.enabled')).toBeUndefined();
130
+ expect(config.environmentVariableMappings()).toMatchObject({
131
+ AUTH_PASSWORD_RESET_MAILER: { path: 'auth.passwordReset.mailer' },
132
+ });
133
+ expect(config.publicValues()).not.toHaveProperty('auth.passwordReset');
134
+ });
@@ -0,0 +1,118 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+ import { resolveStandaloneAppRuntime } from '@nocobase/app-server/node';
7
+ import { createJobExecutorService } from '@nocobase/jobs';
8
+ import { createQueueService } from '@nocobase/queue';
9
+ import appRuntime from '../../server/runtime.ts';
10
+
11
+ const directories: string[] = [];
12
+ afterEach(async () => {
13
+ for (const directory of directories.splice(0))
14
+ await rm(directory, { recursive: true, force: true });
15
+ });
16
+ async function runtime(yaml: string) {
17
+ const root = await mkdtemp(path.join(tmpdir(), 'backends-template-'));
18
+ directories.push(root);
19
+ const configPath = path.join(root, 'config.yml');
20
+ await writeFile(configPath, yaml);
21
+ return resolveStandaloneAppRuntime(appRuntime, {
22
+ rootDir: fileURLToPath(new URL('../..', import.meta.url)),
23
+ configPath,
24
+ env: { AUTH_SECRET: 'test-auth-secret-at-least-32-characters' },
25
+ });
26
+ }
27
+ describe('merged backend configuration in the real template', () => {
28
+ it('keeps local defaults without additional user configuration', async () => {
29
+ const resolved = await runtime('{}');
30
+ expect(resolved.config.get('concurrency.default')).toBe('memory');
31
+ expect(resolved.config.get('concurrency.backends.memory')).toEqual({
32
+ adapter: 'mutex',
33
+ });
34
+ expect(resolved.config.get('jobs.default')).toBeUndefined();
35
+ expect(resolved.config.get('queue.default')).toBeUndefined();
36
+ expect(resolved.config.get('jobs.backends.memory')).toEqual({
37
+ adapter: 'memory',
38
+ persistence: { path: resolved.paths.storage('jobs') },
39
+ });
40
+ expect(resolved.config.get('queue.backends.memory')).toEqual({
41
+ adapter: 'inMemory',
42
+ persistence: { path: resolved.paths.storage('queue') },
43
+ });
44
+ const issues = await resolved.config.validate();
45
+ expect(
46
+ issues.filter((issue) =>
47
+ /^(concurrency|jobs|queue)(\.|$)/u.test(issue.path),
48
+ ),
49
+ ).toEqual([]);
50
+ const jobs = createJobExecutorService(resolved.config.get('jobs'), {
51
+ appName: 'app',
52
+ storagePath: resolved.paths.storage('jobs'),
53
+ });
54
+ const queue = createQueueService(resolved.config.get('queue'), {
55
+ appName: 'app',
56
+ storagePath: resolved.paths.storage('queue'),
57
+ });
58
+ try {
59
+ expect(jobs.getJobExecutor('scope')).toBeDefined();
60
+ await queue.setup();
61
+ } finally {
62
+ await jobs.shutdown();
63
+ await queue.shutdown();
64
+ }
65
+ });
66
+ it.each(['concurrency', 'jobs', 'queue'])(
67
+ 'rejects old user YAML merged over new %s defaults, selected or not',
68
+ async (section) => {
69
+ const adapter =
70
+ section === 'concurrency'
71
+ ? 'mutex'
72
+ : section === 'queue'
73
+ ? 'inMemory'
74
+ : 'memory';
75
+ for (const selector of [
76
+ '',
77
+ ' default: memory\n',
78
+ ' default: legacy\n',
79
+ ]) {
80
+ const resolved = await runtime(
81
+ `${section}:\n${selector} legacy:\n adapter: ${adapter}\n`,
82
+ );
83
+ const issues = await resolved.config.validate();
84
+ expect(issues).toEqual(
85
+ expect.arrayContaining([
86
+ expect.objectContaining({
87
+ level: 'error',
88
+ message: expect.stringContaining('old configuration format'),
89
+ }),
90
+ ]),
91
+ );
92
+ expect(JSON.stringify(issues)).toContain(`${section}.backends.legacy`);
93
+ }
94
+ },
95
+ );
96
+ it.each(['concurrency', 'jobs', 'queue'])(
97
+ 'rejects same-name legacy overrides for %s',
98
+ async (section) => {
99
+ const adapter =
100
+ section === 'concurrency'
101
+ ? 'mutex'
102
+ : section === 'queue'
103
+ ? 'inMemory'
104
+ : 'memory';
105
+ const resolved = await runtime(
106
+ `${section}:\n default: memory\n memory:\n adapter: ${adapter}\n`,
107
+ );
108
+ expect(await resolved.config.validate()).toEqual(
109
+ expect.arrayContaining([
110
+ expect.objectContaining({
111
+ level: 'error',
112
+ message: expect.stringContaining(`${section}.backends.memory`),
113
+ }),
114
+ ]),
115
+ );
116
+ },
117
+ );
118
+ });
@@ -81,7 +81,7 @@ test('the real default composition supplies one memory lock domain to two busine
81
81
  const second = new BusinessConsumer(service);
82
82
  expect(app.config.get('concurrency')).toEqual({
83
83
  default: 'memory',
84
- memory: { adapter: 'mutex' },
84
+ backends: { memory: { adapter: 'mutex' } },
85
85
  });
86
86
  const [left, right] = await Promise.all([first.manager(), second.manager()]);
87
87
  expect(left).toBe(right);
@@ -52,14 +52,16 @@ describe('application config', () => {
52
52
  });
53
53
 
54
54
  expect(runtime.config.get<AppJobsConfig>('jobs')).toEqual({
55
- memory: {
56
- adapter: 'memory',
57
- persistence: { path: runtime.paths.storage('jobs') },
58
- },
59
55
  default: 'redis',
60
- redis: {
61
- adapter: 'redis',
62
- connection: { host: '127.0.0.1', port: 6379 },
56
+ backends: {
57
+ memory: {
58
+ adapter: 'memory',
59
+ persistence: { path: runtime.paths.storage('jobs') },
60
+ },
61
+ redis: {
62
+ adapter: 'redis',
63
+ connection: { host: '127.0.0.1', port: 6379 },
64
+ },
63
65
  },
64
66
  });
65
67
  });
@@ -103,15 +105,19 @@ describe('application config', () => {
103
105
  expect(runtime.config.get('logging.file.name')).toBe('app');
104
106
  // No default: queues run on the built-in memory configuration until one is named.
105
107
  expect(runtime.config.get<AppQueueConfig>('queue')).toEqual({
106
- memory: {
107
- adapter: 'inMemory',
108
- persistence: { path: runtime.paths.storage('queue') },
108
+ backends: {
109
+ memory: {
110
+ adapter: 'inMemory',
111
+ persistence: { path: runtime.paths.storage('queue') },
112
+ },
109
113
  },
110
114
  });
111
115
  expect(runtime.config.get<AppJobsConfig>('jobs')).toEqual({
112
- memory: {
113
- adapter: 'memory',
114
- persistence: { path: runtime.paths.storage('jobs') },
116
+ backends: {
117
+ memory: {
118
+ adapter: 'memory',
119
+ persistence: { path: runtime.paths.storage('jobs') },
120
+ },
115
121
  },
116
122
  });
117
123
  expect(runtime.config.get<AppSessionConfigInput>('session')!.default).toBe(
@@ -203,7 +209,9 @@ describe('application config', () => {
203
209
  expect(runtime.config.get('server.port')).toBe(14001);
204
210
  expect(runtime.config.get('server.startLog')).toBe(false);
205
211
  expect(runtime.config.get('queue.redis')).toBeUndefined();
206
- expect(runtime.config.get('queue.memory.adapter')).toBe('inMemory');
212
+ expect(runtime.config.get('queue.backends.memory.adapter')).toBe(
213
+ 'inMemory',
214
+ );
207
215
  expect(runtime.config.get('session.stores.redis.host')).toBe('127.0.0.1');
208
216
  expect(runtime.config.get('logging.console.pretty')).toBe(false);
209
217
  expect(runtime.config.get('session.cookie.secure')).toBe(true);
@@ -51,7 +51,7 @@ describe('app service providers', () => {
51
51
  },
52
52
  queue: {
53
53
  default: 'memory',
54
- memory: { adapter: 'inMemory' },
54
+ backends: { memory: { adapter: 'inMemory' } },
55
55
  },
56
56
  session: createNullSessionConfig(),
57
57
  snowflake: {
@@ -142,7 +142,7 @@ describe('app service providers', () => {
142
142
  } as unknown as DatabaseManager;
143
143
  const queueConfig = {
144
144
  default: 'test',
145
- test: { adapter: 'inMemory' as const },
145
+ backends: { test: { adapter: 'inMemory' as const } },
146
146
  };
147
147
  const app = createProviderApplication(
148
148
  {
@@ -0,0 +1,114 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterAll, beforeAll, expect, it } from 'vitest';
5
+ import { resolveStandaloneAppRuntime } from '@nocobase/app-server/node';
6
+ import {
7
+ createRealtimeBrokerRegistry,
8
+ realtimeServiceOptions,
9
+ type AppRealtimeConfig,
10
+ } from '@nocobase/app-server/realtime';
11
+ import { createInMemoryBroker } from '@nocobase/realtime/server';
12
+ import appRuntime from '../../server/runtime.js';
13
+
14
+ let root: string;
15
+ beforeAll(async () => {
16
+ root = await mkdtemp(path.join(os.tmpdir(), 'realtime-config-'));
17
+ });
18
+ afterAll(async () => {
19
+ await rm(root, { recursive: true, force: true });
20
+ });
21
+
22
+ async function load(yaml: string) {
23
+ const configPath = path.join(root, `${crypto.randomUUID()}.yml`);
24
+ await writeFile(configPath, yaml);
25
+ const runtime = await resolveStandaloneAppRuntime(appRuntime, {
26
+ rootDir: path.resolve(import.meta.dirname, '../..'),
27
+ configPath,
28
+ env: { AUTH_SECRET: 'test-auth-secret-at-least-32-characters' },
29
+ });
30
+ const issues = (await runtime.config.validate()).filter(
31
+ (issue) => issue.path === 'realtime' || issue.path.startsWith('realtime.'),
32
+ );
33
+ if (issues.length)
34
+ throw new Error(
35
+ issues.map((issue) => `${issue.path} ${issue.message}`).join(' '),
36
+ );
37
+ return runtime;
38
+ }
39
+
40
+ it('loads the actual commented Redis example through the application runtime without exposing credentials', async () => {
41
+ const source = await readFile(
42
+ new URL('../../config.example.yml', import.meta.url),
43
+ 'utf8',
44
+ );
45
+ const example = source.match(/^# realtime:\n(?:#(?: {2,}.*)?\n)*/m)?.[0];
46
+ expect(example).toBeDefined();
47
+ const runtime = await load(
48
+ source.replace(example!, example!.replace(/^# ?/gm, '')),
49
+ );
50
+ const config = runtime.config.get<AppRealtimeConfig>('realtime');
51
+ expect(config).toMatchObject({
52
+ namespace: 'my-app',
53
+ authorizationTimeoutMs: 5000,
54
+ sessionCheckIntervalMs: 30000,
55
+ broker: {
56
+ adapter: 'redis',
57
+ setupTimeoutMs: 5000,
58
+ connection: { host: '127.0.0.1', port: 6379, commandTimeout: 5000 },
59
+ },
60
+ limits: { maxConnections: 10000 },
61
+ });
62
+ expect(
63
+ (await runtime.config.validate()).filter((issue) =>
64
+ issue.path.startsWith('realtime'),
65
+ ),
66
+ ).toEqual([]);
67
+ expect(
68
+ runtime.config.publicPaths().some((p) => p.startsWith('realtime')),
69
+ ).toBe(false);
70
+ // Configuration loads without commercial dependencies, but Redis selection still requires registration.
71
+ expect(() => realtimeServiceOptions(config)).toThrow(/not registered/u);
72
+ const registry = createRealtimeBrokerRegistry();
73
+ registry.register('redis', (context) => {
74
+ expect(context.config).toBe(config);
75
+ expect(context.namespace).toBe('my-app');
76
+ return createInMemoryBroker();
77
+ });
78
+ await realtimeServiceOptions(
79
+ config,
80
+ 'application',
81
+ registry,
82
+ ).broker?.shutdown();
83
+ });
84
+
85
+ it.each([
86
+ '{}',
87
+ 'realtime:\n limits:\n maxConnections: 10',
88
+ 'realtime:\n broker:\n adapter: inMemory',
89
+ ])('keeps local operation for %s', async (yaml) => {
90
+ const runtime = await load(yaml);
91
+ const config = runtime.config.get<AppRealtimeConfig>('realtime');
92
+ expect(config?.broker?.adapter).not.toBe('redis');
93
+ const options = realtimeServiceOptions(config);
94
+ await options.broker?.shutdown();
95
+ });
96
+
97
+ it.each(['{}', 'null', '[]', 'redis', '{ adapter: "" }', '{ adapter: 12 }'])(
98
+ 'does not fill in a missing adapter for explicit broker %s',
99
+ async (broker) => {
100
+ await expect(
101
+ load(`realtime:\n broker: ${broker}`).then(() => undefined),
102
+ ).rejects.toThrow(/realtime.broker/u);
103
+ },
104
+ );
105
+
106
+ it.each([
107
+ 'adapter: inMemory',
108
+ 'redis: {}',
109
+ 'adapter: redis\n broker: { adapter: redis }',
110
+ ])('rejects legacy fields in runtime configuration: %s', async (legacy) => {
111
+ await expect(
112
+ load(`realtime:\n ${legacy}`).then(() => undefined),
113
+ ).rejects.toThrow(/old configuration format/u);
114
+ });
@@ -1,62 +0,0 @@
1
- import {
2
- Bell,
3
- CalendarClock,
4
- Database,
5
- KeyRound,
6
- Languages,
7
- Mail,
8
- Palette,
9
- Paperclip,
10
- ShieldCheck,
11
- Workflow,
12
- type LucideIcon,
13
- } from 'lucide-react';
14
-
15
- /**
16
- * A built-in capability the homepage introduces. Its title, description and prompts live in the locale files under
17
- * `home.capabilities.<id>`, one `prompts.<promptId>.label` and `prompts.<promptId>.text` per prompt, so the wording is
18
- * translated and reworded there rather than here.
19
- */
20
- export interface Capability {
21
- readonly id: string;
22
- readonly icon: LucideIcon;
23
- readonly prompts: readonly string[];
24
- }
25
-
26
- // The default prompts follow the examples in the documentation's Capabilities chapter, in the order of its sidebar.
27
- export const CAPABILITIES: readonly Capability[] = [
28
- {
29
- id: 'auth',
30
- icon: KeyRound,
31
- prompts: ['disableSignUp', 'companySso', 'apiKeys'],
32
- },
33
- {
34
- id: 'dataSources',
35
- icon: Database,
36
- prompts: ['postgres', 'externalDatabase'],
37
- },
38
- {
39
- id: 'authorization',
40
- icon: ShieldCheck,
41
- prompts: ['jobs', 'dataScopes', 'restrictions'],
42
- },
43
- {
44
- id: 'workflow',
45
- icon: Workflow,
46
- prompts: ['stages', 'backgroundExport', 'history'],
47
- },
48
- { id: 'file', icon: Paperclip, prompts: ['attachments', 'avatar'] },
49
- { id: 'i18n', icon: Languages, prompts: ['addLanguage', 'defaultLanguage'] },
50
- { id: 'theme', icon: Palette, prompts: ['changeTheme', 'newTheme'] },
51
- { id: 'notification', icon: Bell, prompts: ['inApp', 'email'] },
52
- {
53
- id: 'scheduler',
54
- icon: CalendarClock,
55
- prompts: ['reminder', 'weeklyReport'],
56
- },
57
- {
58
- id: 'mail',
59
- icon: Mail,
60
- prompts: ['providers', 'mailCenter', 'correspondence'],
61
- },
62
- ];
@@ -1,122 +0,0 @@
1
- import { useTranslation } from '@nocobase/i18n/client';
2
- import { CheckIcon, CopyIcon, RotateCcwIcon } from 'lucide-react';
3
- import { type ReactElement, useState } from 'react';
4
-
5
- import { Button } from '#components/ui/button';
6
- import {
7
- Dialog,
8
- DialogClose,
9
- DialogContent,
10
- DialogDescription,
11
- DialogFooter,
12
- DialogHeader,
13
- DialogTitle,
14
- } from '#components/ui/dialog';
15
- import { Tabs, TabsList, TabsTrigger } from '#components/ui/tabs';
16
- import { Textarea } from '#components/ui/textarea';
17
-
18
- import type { Capability } from './capabilities.js';
19
- import { useCopy } from './use-copy.js';
20
-
21
- export interface PromptDialogProps {
22
- readonly capability: Capability;
23
- readonly open: boolean;
24
- readonly onOpenChange: (open: boolean) => void;
25
- }
26
-
27
- /**
28
- * The prompts of one capability, ready to hand to a coding agent. Each prompt can be edited before copying. Edits are
29
- * not saved: the page mounts a new dialog each time one is opened, so it starts from the default wording again.
30
- */
31
- export function PromptDialog({
32
- capability,
33
- open,
34
- onOpenChange,
35
- }: PromptDialogProps): ReactElement {
36
- const { t } = useTranslation();
37
- const { copied, copy } = useCopy();
38
- const [current, setCurrent] = useState(capability.prompts[0]);
39
- // Edited text per prompt id; a prompt without an entry shows its default wording.
40
- const [edits, setEdits] = useState<Readonly<Record<string, string>>>({});
41
-
42
- const prefix = `home.capabilities.${capability.id}`;
43
- const defaultText = t(`${prefix}.prompts.${current}.text`);
44
- const text = edits[current] ?? defaultText;
45
- const Icon = capability.icon;
46
-
47
- return (
48
- <Dialog open={open} onOpenChange={onOpenChange}>
49
- <DialogContent className='gap-5 sm:max-w-2xl'>
50
- <DialogHeader className='flex-row items-center gap-3 pr-8'>
51
- <span className='flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/5 text-primary'>
52
- <Icon className='size-5' aria-hidden />
53
- </span>
54
- <div className='flex min-w-0 flex-col gap-1.5'>
55
- <DialogTitle>{t(`${prefix}.title`)}</DialogTitle>
56
- <DialogDescription>{t(`${prefix}.description`)}</DialogDescription>
57
- </div>
58
- </DialogHeader>
59
- <div className='flex flex-col gap-3'>
60
- {capability.prompts.length > 1 ? (
61
- <Tabs
62
- value={current}
63
- onValueChange={(value) => {
64
- if (typeof value === 'string') setCurrent(value);
65
- }}
66
- >
67
- <TabsList className='w-full'>
68
- {capability.prompts.map((id) => (
69
- <TabsTrigger key={id} value={id}>
70
- {t(`${prefix}.prompts.${id}.label`)}
71
- </TabsTrigger>
72
- ))}
73
- </TabsList>
74
- </Tabs>
75
- ) : null}
76
- <Textarea
77
- aria-label={t('home.prompt.label')}
78
- className='field-sizing-fixed h-60 max-h-[50dvh] resize-none leading-6'
79
- value={text}
80
- onChange={(event) =>
81
- setEdits((previous) => ({
82
- ...previous,
83
- [current]: event.target.value,
84
- }))
85
- }
86
- />
87
- <p className='text-xs text-muted-foreground'>
88
- {t('home.prompt.hint')}
89
- </p>
90
- </div>
91
- <DialogFooter className='sm:items-center'>
92
- <Button
93
- variant='ghost'
94
- className='sm:mr-auto'
95
- disabled={text === defaultText}
96
- onClick={() =>
97
- setEdits((previous) => {
98
- const rest = { ...previous };
99
- delete rest[current];
100
- return rest;
101
- })
102
- }
103
- >
104
- <RotateCcwIcon data-icon='inline-start' />
105
- {t('home.prompt.reset')}
106
- </Button>
107
- <DialogClose render={<Button variant='outline' />}>
108
- {t('actions.close')}
109
- </DialogClose>
110
- <Button disabled={text.trim() === ''} onClick={() => void copy(text)}>
111
- {copied ? (
112
- <CheckIcon data-icon='inline-start' />
113
- ) : (
114
- <CopyIcon data-icon='inline-start' />
115
- )}
116
- {copied ? t('home.prompt.copiedShort') : t('home.prompt.copy')}
117
- </Button>
118
- </DialogFooter>
119
- </DialogContent>
120
- </Dialog>
121
- );
122
- }