@nocobase/client-v2 2.1.0-beta.29 → 2.1.0-beta.32

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 (100) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/PluginManager.d.ts +1 -0
  3. package/es/components/form/DrawerFormLayout.d.ts +49 -0
  4. package/es/components/form/EnvVariableInput.d.ts +42 -0
  5. package/es/components/form/FileSizeInput.d.ts +27 -0
  6. package/es/components/form/createFormRegistry.d.ts +33 -0
  7. package/es/components/form/index.d.ts +13 -0
  8. package/es/components/index.d.ts +1 -1
  9. package/es/flow/actions/index.d.ts +1 -1
  10. package/es/flow/actions/linkageRules.d.ts +2 -0
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +4 -0
  12. package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -0
  13. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +5 -0
  14. package/es/flow/components/FieldAssignRulesEditor.d.ts +1 -0
  15. package/es/flow/internal/utils/enumOptionsUtils.d.ts +5 -0
  16. package/es/flow/models/actions/AssociationActionUtils.d.ts +5 -0
  17. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +4 -0
  18. package/es/flow/models/blocks/filter-manager/FilterManager.d.ts +5 -1
  19. package/es/flow/models/blocks/table/TableSelectModel.d.ts +8 -0
  20. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +3 -0
  21. package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +1 -1
  22. package/es/flow/models/utils/displayValueUtils.d.ts +12 -0
  23. package/es/flow-compat/passwordUtils.d.ts +1 -1
  24. package/es/index.mjs +122 -106
  25. package/es/utils/remotePlugins.d.ts +0 -4
  26. package/lib/index.js +121 -105
  27. package/package.json +6 -5
  28. package/src/BaseApplication.tsx +14 -8
  29. package/src/PluginManager.ts +1 -0
  30. package/src/__tests__/app.test.tsx +28 -1
  31. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +67 -46
  32. package/src/__tests__/remotePlugins.test.ts +29 -18
  33. package/src/__tests__/settings-center.test.tsx +30 -0
  34. package/src/components/form/DrawerFormLayout.tsx +103 -0
  35. package/src/components/form/EnvVariableInput.tsx +126 -0
  36. package/src/components/form/FileSizeInput.tsx +105 -0
  37. package/src/components/form/createFormRegistry.ts +60 -0
  38. package/src/components/form/index.tsx +14 -0
  39. package/src/components/index.ts +1 -1
  40. package/src/flow/__tests__/FlowRoute.test.tsx +4 -5
  41. package/src/flow/actions/__tests__/actionLinkageRules.race.repro.test.ts +199 -0
  42. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +92 -13
  43. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +6 -1
  44. package/src/flow/actions/__tests__/linkageRules.menu.test.ts +90 -0
  45. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +476 -1
  46. package/src/flow/actions/index.ts +2 -0
  47. package/src/flow/actions/linkageRules.tsx +316 -280
  48. package/src/flow/actions/linkageRulesFormValueRefresh.ts +2 -8
  49. package/src/flow/actions/setTargetDataScope.tsx +32 -3
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +8 -1
  51. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +70 -12
  52. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +26 -87
  53. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +11 -0
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -1
  55. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +292 -31
  56. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +50 -12
  57. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +77 -56
  58. package/src/flow/components/AdminLayout.tsx +2 -2
  59. package/src/flow/components/FieldAssignRulesEditor.tsx +2 -0
  60. package/src/flow/components/FlowRoute.tsx +17 -4
  61. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  62. package/src/flow/components/filter/LinkageFilterItem.tsx +9 -2
  63. package/src/flow/components/filter/VariableFilterItem.tsx +2 -6
  64. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +71 -0
  65. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -0
  66. package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -1
  67. package/src/flow/internal/utils/enumOptionsUtils.ts +29 -0
  68. package/src/flow/models/actions/AssociateActionModel.tsx +2 -2
  69. package/src/flow/models/actions/AssociationActionUtils.ts +14 -0
  70. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +63 -0
  71. package/src/flow/models/base/CollectionBlockModel.tsx +7 -0
  72. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +33 -9
  73. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +53 -13
  74. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +63 -3
  75. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +33 -1
  76. package/src/flow/models/blocks/filter-manager/FilterManager.ts +66 -2
  77. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +270 -0
  78. package/src/flow/models/blocks/form/FormBlockModel.tsx +8 -5
  79. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +30 -0
  80. package/src/flow/models/blocks/form/value-runtime/rules.ts +6 -1
  81. package/src/flow/models/blocks/form/value-runtime/runtime.ts +6 -1
  82. package/src/flow/models/blocks/table/TableBlockModel.tsx +11 -6
  83. package/src/flow/models/blocks/table/TableColumnModel.tsx +3 -0
  84. package/src/flow/models/blocks/table/TableSelectModel.tsx +36 -26
  85. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowClick.test.ts +69 -0
  86. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +96 -1
  87. package/src/flow/models/blocks/table/__tests__/TableSelectModel.test.ts +41 -0
  88. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -0
  89. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +7 -0
  90. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +4 -1
  91. package/src/flow/models/fields/ClickableFieldModel.tsx +9 -4
  92. package/src/flow/models/fields/DisplayTitleFieldModel.tsx +12 -4
  93. package/src/flow/models/fields/SelectFieldModel.tsx +31 -1
  94. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +23 -0
  95. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +2 -1
  96. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +7 -0
  97. package/src/flow/models/utils/displayValueUtils.ts +57 -0
  98. package/src/flow/system-settings/useSystemSettings.tsx +36 -1
  99. package/src/utils/globalDeps.ts +2 -0
  100. package/src/utils/remotePlugins.ts +7 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.0-beta.29",
3
+ "version": "2.1.0-beta.32",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -24,11 +24,12 @@
24
24
  "@formily/antd-v5": "1.2.3",
25
25
  "@formily/react": "^2.2.27",
26
26
  "@formily/shared": "^2.2.27",
27
- "@nocobase/flow-engine": "2.1.0-beta.29",
28
- "@nocobase/sdk": "2.1.0-beta.29",
29
- "@nocobase/shared": "2.1.0-beta.29",
27
+ "@nocobase/flow-engine": "2.1.0-beta.32",
28
+ "@nocobase/sdk": "2.1.0-beta.32",
29
+ "@nocobase/shared": "2.1.0-beta.32",
30
30
  "ahooks": "^3.7.2",
31
31
  "antd": "5.24.2",
32
+ "axios": "^1.7.0",
32
33
  "classnames": "^2.3.1",
33
34
  "dayjs": "^1.11.10",
34
35
  "i18next": "^22.4.9",
@@ -37,5 +38,5 @@
37
38
  "react-i18next": "^11.15.1",
38
39
  "react-router-dom": "^6.30.1"
39
40
  },
40
- "gitHead": "86c41be29dcbcac6fd6aa46b4a137ef07a27c1d0"
41
+ "gitHead": "659c5efe992da7118d33c768bbd9e837a2c4716f"
41
42
  }
@@ -586,19 +586,25 @@ export class ApplicationModel extends FlowModel {
586
586
  }
587
587
 
588
588
  render() {
589
- if (this.app.maintaining) {
590
- return this.renderMaintaining();
589
+ if (this.app.maintaining && !this.app.maintained) {
590
+ return <>{this.renderMaintaining()}</>;
591
591
  }
592
- if (this.app.error) {
593
- return this.renderError();
592
+ if (this.app.error && !this.app.maintaining) {
593
+ return <>{this.renderError()}</>;
594
594
  }
595
- return this.renderContent();
595
+ return (
596
+ <>
597
+ {this.renderContent()}
598
+ {this.app.maintaining && this.app.maintained ? this.renderMaintainingDialog() : null}
599
+ </>
600
+ );
596
601
  }
597
602
 
598
603
  renderMaintaining() {
599
- if (!this.app.maintained) {
600
- return this.app.renderComponent('AppMaintaining', { app: this.app, error: this.app.error });
601
- }
604
+ return this.app.renderComponent('AppMaintaining', { app: this.app, error: this.app.error });
605
+ }
606
+
607
+ renderMaintainingDialog() {
602
608
  return this.app.renderComponent('AppMaintainingDialog', { app: this.app, error: this.app.error });
603
609
  }
604
610
 
@@ -25,6 +25,7 @@ export type PluginData = {
25
25
  packageName: string;
26
26
  version: string;
27
27
  url: string;
28
+ clientV2Url?: string;
28
29
  type: 'local' | 'upload' | 'npm';
29
30
  };
30
31
 
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { Application, createMockClient, NocoBaseBuildInPlugin, Plugin } from '@nocobase/client-v2';
11
11
  import { useFlowEngineContext } from '@nocobase/flow-engine';
12
- import { act, render, screen, waitFor } from '@testing-library/react';
12
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
13
13
  import React from 'react';
14
14
  import { Outlet } from 'react-router-dom';
15
15
 
@@ -254,6 +254,33 @@ describe('app', () => {
254
254
  expect(screen.getByText('maintaining dialog message')).toBeInTheDocument();
255
255
  });
256
256
 
257
+ it('should keep current content behind maintained dialog state', async () => {
258
+ const CurrentPage = () => {
259
+ const [count, setCount] = React.useState(0);
260
+ return <button onClick={() => setCount((value) => value + 1)}>Current page count: {count}</button>;
261
+ };
262
+
263
+ class PluginHelloClient extends Plugin {
264
+ async load() {
265
+ this.router.add('root', { path: '/', Component: CurrentPage });
266
+ }
267
+ }
268
+ const app = createMockClient({ plugins: [PluginHelloClient] });
269
+ await renderApp(app);
270
+ fireEvent.click(screen.getByRole('button', { name: 'Current page count: 0' }));
271
+ expect(screen.getByRole('button', { name: 'Current page count: 1' })).toBeInTheDocument();
272
+
273
+ act(() => {
274
+ app.maintained = true;
275
+ app.maintaining = true;
276
+ app.error = { code: 'APP_COMMANDING', command: { name: 'pm.enable' }, message: 'starting sub applications...' };
277
+ });
278
+
279
+ expect(screen.getByRole('button', { name: 'Current page count: 1' })).toBeInTheDocument();
280
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
281
+ expect(screen.getByText('Enabling plugin')).toBeInTheDocument();
282
+ });
283
+
257
284
  it('should handle long loading state gracefully', async () => {
258
285
  class PluginHelloClient extends Plugin {
259
286
  async load() {
@@ -8,13 +8,29 @@
8
8
  */
9
9
 
10
10
  import { createMockClient } from '@nocobase/client-v2';
11
- import { render, waitFor } from '@testing-library/react';
11
+ import { render, screen, waitFor } from '@testing-library/react';
12
12
  import React from 'react';
13
13
  import { NocoBaseBuildInPlugin } from '../nocobase-buildin-plugin';
14
14
 
15
15
  describe('nocobase buildin plugin auth redirect', () => {
16
16
  const originalLocation = globalThis.window.location;
17
17
 
18
+ beforeEach(() => {
19
+ Object.defineProperty(globalThis.window, 'matchMedia', {
20
+ configurable: true,
21
+ value: vi.fn().mockImplementation((query: string) => ({
22
+ matches: false,
23
+ media: query,
24
+ onchange: null,
25
+ addListener: vi.fn(),
26
+ removeListener: vi.fn(),
27
+ addEventListener: vi.fn(),
28
+ removeEventListener: vi.fn(),
29
+ dispatchEvent: vi.fn(),
30
+ })),
31
+ });
32
+ });
33
+
18
34
  afterEach(() => {
19
35
  Object.defineProperty(globalThis.window, 'location', {
20
36
  configurable: true,
@@ -92,7 +108,7 @@ describe('nocobase buildin plugin auth redirect', () => {
92
108
  });
93
109
  });
94
110
 
95
- it('should redirect authenticated v2 admin root to legacy default page before layout render', async () => {
111
+ it('should render v2 admin root without redirecting to legacy default page', async () => {
96
112
  const replace = vi.fn();
97
113
  Object.defineProperty(globalThis.window, 'location', {
98
114
  configurable: true,
@@ -118,6 +134,7 @@ describe('nocobase buildin plugin auth redirect', () => {
118
134
  },
119
135
  });
120
136
  app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
137
+ app.apiMock.onGet('systemSettings:get').reply(200, { data: {} });
121
138
  app.apiMock.onGet('/desktopRoutes:listAccessible').reply(200, {
122
139
  data: [
123
140
  {
@@ -133,53 +150,57 @@ describe('nocobase buildin plugin auth redirect', () => {
133
150
  const { container } = render(<Root />);
134
151
 
135
152
  await waitFor(() => {
136
- expect(replace).toHaveBeenCalledWith('/admin/legacy-page');
153
+ expect(container.innerHTML).toContain('No pages yet, please configure first');
137
154
  });
155
+ expect(replace).not.toHaveBeenCalled();
138
156
  expect(container.innerHTML).not.toContain('Legacy page');
139
157
  });
140
158
 
141
- it('should redirect authenticated direct legacy v2 page access to v1 path', async () => {
142
- const replace = vi.fn();
143
- Object.defineProperty(globalThis.window, 'location', {
144
- configurable: true,
145
- value: {
146
- ...originalLocation,
147
- pathname: '/v2/admin/legacy-page/tab/tab-1',
148
- search: '?from=direct',
149
- hash: '#dialog',
150
- replace,
151
- },
152
- });
153
-
154
- const app = createMockClient({
155
- publicPath: '/v2/',
156
- plugins: [NocoBaseBuildInPlugin as any],
157
- router: { type: 'memory', initialEntries: ['/v2/admin/legacy-page/tab/tab-1'] },
158
- });
159
- app.apiMock.onGet('app:getLang').reply(200, {
160
- data: {
161
- lang: 'en-US',
162
- resources: { client: {} },
163
- cron: {},
164
- },
165
- });
166
- app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
167
- app.apiMock.onGet('/desktopRoutes:listAccessible').reply(200, {
168
- data: [
169
- {
170
- id: 1,
171
- title: 'Legacy page',
172
- schemaUid: 'legacy-page',
173
- type: 'page',
159
+ it.each(['/v2/admin/legacy-page/tab/tab-1', '/v2/admin/legacy-page/view/detail'])(
160
+ 'should show 404 for authenticated direct legacy v2 page access: %s',
161
+ async (pathname) => {
162
+ const replace = vi.fn();
163
+ Object.defineProperty(globalThis.window, 'location', {
164
+ configurable: true,
165
+ value: {
166
+ ...originalLocation,
167
+ pathname,
168
+ search: '?from=direct',
169
+ hash: '#dialog',
170
+ replace,
174
171
  },
175
- ],
176
- });
177
-
178
- const Root = app.getRootComponent();
179
- render(<Root />);
180
-
181
- await waitFor(() => {
182
- expect(replace).toHaveBeenCalledWith('/admin/legacy-page/tabs/tab-1?from=direct#dialog');
183
- });
184
- });
172
+ });
173
+
174
+ const app = createMockClient({
175
+ publicPath: '/v2/',
176
+ plugins: [NocoBaseBuildInPlugin as any],
177
+ router: { type: 'memory', initialEntries: [pathname] },
178
+ });
179
+ app.apiMock.onGet('app:getLang').reply(200, {
180
+ data: {
181
+ lang: 'en-US',
182
+ resources: { client: {} },
183
+ cron: {},
184
+ },
185
+ });
186
+ app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
187
+ app.apiMock.onGet('systemSettings:get').reply(200, { data: {} });
188
+ app.apiMock.onGet('/desktopRoutes:listAccessible').reply(200, {
189
+ data: [
190
+ {
191
+ id: 1,
192
+ title: 'Legacy page',
193
+ schemaUid: 'legacy-page',
194
+ type: 'page',
195
+ },
196
+ ],
197
+ });
198
+
199
+ const Root = app.getRootComponent();
200
+ render(<Root />);
201
+
202
+ expect(await screen.findByText('404')).toBeInTheDocument();
203
+ expect(replace).not.toHaveBeenCalled();
204
+ },
205
+ );
185
206
  });
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { Plugin } from '../Plugin';
11
11
  import { getRequireJs } from '../utils/requirejs';
12
- import { defineDevPlugins, definePluginClient, getPlugins } from '../utils/remotePlugins';
12
+ import { configRequirejs, defineDevPlugins, getPlugins } from '../utils/remotePlugins';
13
13
 
14
14
  describe('client-v2 remotePlugins', () => {
15
15
  afterEach(() => {
@@ -29,19 +29,6 @@ describe('client-v2 remotePlugins', () => {
29
29
  expect(mockDefine).toHaveBeenCalledWith('@nocobase/demo/client-v2', expect.any(Function));
30
30
  });
31
31
 
32
- it('should define remote plugin proxies with /client-v2 module ids', () => {
33
- const mockDefine: any = vi.fn();
34
- window.define = mockDefine;
35
-
36
- definePluginClient('@nocobase/demo');
37
-
38
- expect(mockDefine).toHaveBeenCalledWith(
39
- '@nocobase/demo/client-v2',
40
- ['exports', '@nocobase/demo'],
41
- expect.any(Function),
42
- );
43
- });
44
-
45
32
  it('should not define /client aliases when loading v2 plugins', async () => {
46
33
  class DemoPlugin extends Plugin {}
47
34
 
@@ -71,16 +58,40 @@ describe('client-v2 remotePlugins', () => {
71
58
  expect(mockDefine).not.toHaveBeenCalledWith('@nocobase/demo/client', expect.any(Function));
72
59
  });
73
60
 
61
+ it('should configure remote plugin paths with /client-v2 module ids', () => {
62
+ const requirejs: any = {
63
+ requirejs: {
64
+ config: vi.fn(),
65
+ },
66
+ };
67
+
68
+ configRequirejs(requirejs, [
69
+ {
70
+ packageName: '@nocobase/demo',
71
+ url: '/static/plugins/@nocobase/demo/dist/client-v2/index.js',
72
+ },
73
+ ] as any);
74
+
75
+ expect(requirejs.requirejs.config).toHaveBeenCalledWith({
76
+ waitSeconds: 120,
77
+ paths: {
78
+ '@nocobase/demo/client-v2': '/static/plugins/@nocobase/demo/dist/client-v2/index.js',
79
+ },
80
+ });
81
+ });
82
+
74
83
  it('should not append duplicate .js for plugin URLs without query strings', () => {
75
84
  const requirejs = getRequireJs();
76
85
 
77
86
  requirejs.requirejs.config({
78
87
  paths: {
79
- '@nocobase/demo': '/static/plugins/@nocobase/demo/dist/client-v2/index.js',
88
+ '@nocobase/demo/client-v2': '/static/plugins/@nocobase/demo/dist/client-v2/index.js',
80
89
  },
81
90
  });
82
91
 
83
- expect(requirejs.requirejs.toUrl('@nocobase/demo')).toBe('/static/plugins/@nocobase/demo/dist/client-v2/index.js');
92
+ expect(requirejs.requirejs.toUrl('@nocobase/demo/client-v2')).toBe(
93
+ '/static/plugins/@nocobase/demo/dist/client-v2/index.js',
94
+ );
84
95
  });
85
96
 
86
97
  it('should keep hashed plugin URLs unchanged', () => {
@@ -88,11 +99,11 @@ describe('client-v2 remotePlugins', () => {
88
99
 
89
100
  requirejs.requirejs.config({
90
101
  paths: {
91
- '@nocobase/demo': '/static/plugins/@nocobase/demo/dist/client-v2/index.js?hash=12345678',
102
+ '@nocobase/demo/client-v2': '/static/plugins/@nocobase/demo/dist/client-v2/index.js?hash=12345678',
92
103
  },
93
104
  });
94
105
 
95
- expect(requirejs.requirejs.toUrl('@nocobase/demo')).toBe(
106
+ expect(requirejs.requirejs.toUrl('@nocobase/demo/client-v2')).toBe(
96
107
  '/static/plugins/@nocobase/demo/dist/client-v2/index.js?hash=12345678',
97
108
  );
98
109
  });
@@ -158,6 +158,36 @@ describe('settings center', () => {
158
158
  expect(await screen.findByDisplayValue('NocoBase')).toBeInTheDocument();
159
159
  });
160
160
 
161
+ it('should expose current language variable as enabled-language selector', async () => {
162
+ const app = createMockClient({
163
+ plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
164
+ router: { type: 'memory', initialEntries: ['/admin/settings/system-settings'] },
165
+ });
166
+ mockAdminRuntime(app, {
167
+ systemSettings: {
168
+ enabledLanguages: ['en-US', 'zh-CN'],
169
+ },
170
+ });
171
+
172
+ await renderApp(app);
173
+ await waitForGetRequests(app, ['/auth:check', 'roles:check', 'systemSettings:get']);
174
+
175
+ await waitFor(() => {
176
+ const localeNode = app.flowEngine.context.getPropertyMetaTree().find((node) => node.name === 'locale');
177
+ expect(localeNode).toMatchObject({
178
+ name: 'locale',
179
+ title: '{{t("Current language")}}',
180
+ interface: 'select',
181
+ uiSchema: {
182
+ enum: [
183
+ { label: 'English', value: 'en-US' },
184
+ { label: '简体中文', value: 'zh-CN' },
185
+ ],
186
+ },
187
+ });
188
+ });
189
+ });
190
+
161
191
  it('should fallback to plugin-manager when system-settings is not allowed', async () => {
162
192
  const app = createMockClient({
163
193
  plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
@@ -0,0 +1,103 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { CloseOutlined } from '@ant-design/icons';
11
+ import { css } from '@emotion/css';
12
+ import { useFlowView } from '@nocobase/flow-engine';
13
+ import { Button, Space } from 'antd';
14
+ import React, { useCallback } from 'react';
15
+
16
+ export interface DrawerFormLayoutProps {
17
+ /** Header title rendered next to the close (X) button. */
18
+ title: React.ReactNode;
19
+ /** Form body — typically a `<Form>` wrapping `<Form.Item>` fields. */
20
+ children: React.ReactNode;
21
+ /**
22
+ * Called before the drawer is closed by either the Cancel button or the
23
+ * header's X icon. Use for "discard changes" confirmations.
24
+ */
25
+ onCancel?: () => void | Promise<void>;
26
+ /**
27
+ * Called when the Submit button is clicked. Caller owns validation + the
28
+ * actual API call; the drawer is closed automatically when `onSubmit`
29
+ * resolves. Throw from `onSubmit` to keep the drawer open (e.g. on a
30
+ * validation error).
31
+ */
32
+ onSubmit?: () => void | Promise<void>;
33
+ /** Drives the Submit button's loading state. */
34
+ submitting?: boolean;
35
+ /** Override the Submit button label. Defaults to "Submit". */
36
+ submitText?: React.ReactNode;
37
+ /** Override the Cancel button label. Defaults to "Cancel". */
38
+ cancelText?: React.ReactNode;
39
+ /**
40
+ * Full override of the footer content. When provided, the default
41
+ * Cancel + Submit buttons are replaced. Useful for forms that need
42
+ * extra actions (e.g. Preview, Save draft).
43
+ */
44
+ footer?: React.ReactNode;
45
+ }
46
+
47
+ const titleClassName = css`
48
+ display: inline-flex;
49
+ align-items: center;
50
+ gap: 8px;
51
+ margin-left: -8px;
52
+ `;
53
+
54
+ /**
55
+ * Standard layout for drawer-hosted forms: a close-icon + title header on
56
+ * top, the caller-provided form body in the middle, and a Cancel + Submit
57
+ * footer at the bottom. Wraps `useFlowView()`'s `Header` / `Footer` slots
58
+ * so the drawer chrome stays consistent across plugins.
59
+ *
60
+ * Callers own the `<Form>` instance, validation, and the actual API call.
61
+ * This component only handles the chrome and the close behaviour.
62
+ */
63
+ export function DrawerFormLayout(props: DrawerFormLayoutProps) {
64
+ const view = useFlowView();
65
+
66
+ const handleCancel = useCallback(async () => {
67
+ await props.onCancel?.();
68
+ await view.close();
69
+ }, [props, view]);
70
+
71
+ const handleSubmit = useCallback(async () => {
72
+ await props.onSubmit?.();
73
+ await view.close();
74
+ }, [props, view]);
75
+
76
+ return (
77
+ <div>
78
+ {view.Header ? (
79
+ <view.Header
80
+ title={
81
+ <span className={titleClassName}>
82
+ <Button type="text" size="small" icon={<CloseOutlined />} onClick={handleCancel} />
83
+ <span>{props.title}</span>
84
+ </span>
85
+ }
86
+ />
87
+ ) : null}
88
+ {props.children}
89
+ {view.Footer ? (
90
+ <view.Footer>
91
+ {props.footer ?? (
92
+ <Space>
93
+ <Button onClick={handleCancel}>{props.cancelText ?? 'Cancel'}</Button>
94
+ <Button type="primary" loading={props.submitting} onClick={handleSubmit}>
95
+ {props.submitText ?? 'Submit'}
96
+ </Button>
97
+ </Space>
98
+ )}
99
+ </view.Footer>
100
+ ) : null}
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import {
11
+ useFlowContext,
12
+ VariableHybridInput,
13
+ type MetaTreeNode,
14
+ type VariableHybridInputConverters,
15
+ } from '@nocobase/flow-engine';
16
+ import { useRequest } from 'ahooks';
17
+ import { Input } from 'antd';
18
+ import React, { useMemo } from 'react';
19
+
20
+ const ENV_EXPR_REGEXP = /\{\{\s*(\$env\.[^{}]+?)\s*\}\}/g;
21
+ const ENV_SINGLE_EXPR_REGEXP = /^\{\{\s*(\$env\.[^{}]+?)\s*\}\}$/;
22
+ const META_TREE_CACHE_KEY = '@nocobase/client-v2:EnvVariableInput:metaTree';
23
+
24
+ /**
25
+ * Convert a stored value like `"{{ $env.foo.bar }}"` back into the
26
+ * `[$env, foo, bar]` path used by the variable picker.
27
+ */
28
+ export function parseEnvPath(value?: string): string[] | undefined {
29
+ const matched = value?.trim().match(ENV_SINGLE_EXPR_REGEXP);
30
+ return matched?.[1] ? matched[1].split('.') : undefined;
31
+ }
32
+
33
+ /**
34
+ * Format a meta tree node back into a `"{{ $env.x.y }}"` server-compatible
35
+ * expression. Used as the `formatPathToValue` converter so the picker output
36
+ * survives a round trip through the API.
37
+ */
38
+ export function formatEnvPath(meta?: MetaTreeNode) {
39
+ const paths = meta?.paths || [];
40
+ if (paths[0] !== '$env' || paths.length < 2) {
41
+ return undefined;
42
+ }
43
+ return `{{ ${paths.join('.')} }}`;
44
+ }
45
+
46
+ /**
47
+ * Pull the `$env` sub-tree off the FlowContext meta registry and eagerly
48
+ * resolve lazy `children` thunks so the picker can render labels on first
49
+ * paint. Empty tree (no env-variables plugin or no defined vars) yields `[]`.
50
+ */
51
+ function useEnvMetaTree(): MetaTreeNode[] {
52
+ const ctx = useFlowContext();
53
+ const { data } = useRequest<MetaTreeNode[], []>(
54
+ async () => {
55
+ const tree = ctx.getPropertyMetaTree().filter((node) => node.name === '$env');
56
+ for (const node of tree) {
57
+ if (typeof node.children === 'function') {
58
+ try {
59
+ const resolved = await (node.children as () => Promise<MetaTreeNode[]>)();
60
+ node.children = Array.isArray(resolved) ? resolved : [];
61
+ } catch {
62
+ node.children = [];
63
+ }
64
+ }
65
+ }
66
+ return tree.filter((node) => Array.isArray(node.children) && node.children.length > 0);
67
+ },
68
+ {
69
+ cacheKey: META_TREE_CACHE_KEY,
70
+ refreshOnWindowFocus: true,
71
+ },
72
+ );
73
+
74
+ return data ?? [];
75
+ }
76
+
77
+ export interface EnvVariableInputProps {
78
+ value?: string;
79
+ onChange?: (value: string) => void;
80
+ addonBefore?: React.ReactNode;
81
+ disabled?: boolean;
82
+ /**
83
+ * When true, plain (non-variable) values are masked via `Input.Password`
84
+ * so secret credentials are not displayed verbatim. Variable expressions
85
+ * remain editable through the variable picker even in password mode.
86
+ */
87
+ password?: boolean;
88
+ placeholder?: string;
89
+ }
90
+
91
+ const isVariableExpr = (value?: string) => typeof value === 'string' && /\{\{\s*[^{}]+?\s*\}\}/.test(value);
92
+
93
+ /**
94
+ * Generic input component for fields that accept either a literal value or a
95
+ * `{{ $env.X }}` reference. The `$env` namespace is wired through the
96
+ * environment-variables plugin's `flowEngine.context.defineProperty('$env', ...)`
97
+ * registration; this component is the single consumption point and degrades
98
+ * gracefully to a plain text input when no env variables are defined.
99
+ */
100
+ export function EnvVariableInput(props: EnvVariableInputProps) {
101
+ const { password, ...rest } = props;
102
+ const metaTree = useEnvMetaTree();
103
+
104
+ const converters = useMemo<VariableHybridInputConverters>(
105
+ () => ({
106
+ formatPathToValue: formatEnvPath,
107
+ parseValueToPath: parseEnvPath,
108
+ variableRegExp: ENV_EXPR_REGEXP,
109
+ }),
110
+ [],
111
+ );
112
+
113
+ if (password && rest.value && !isVariableExpr(rest.value)) {
114
+ return (
115
+ <Input.Password
116
+ disabled={rest.disabled}
117
+ placeholder={rest.placeholder}
118
+ value={rest.value}
119
+ onChange={(event) => rest.onChange?.(event.target.value)}
120
+ autoFocus
121
+ />
122
+ );
123
+ }
124
+
125
+ return <VariableHybridInput {...rest} converters={converters} metaTree={metaTree} />;
126
+ }