@nocobase/client-v2 2.2.0-alpha.10 → 2.2.0-alpha.12

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 (87) hide show
  1. package/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/authRedirect.d.ts +5 -0
  3. package/es/components/AttachmentUpload.d.ts +51 -0
  4. package/es/components/index.d.ts +1 -0
  5. package/es/flow/actions/linkageRules.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  7. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  8. package/es/index.d.ts +4 -1
  9. package/es/index.mjs +123 -112
  10. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  11. package/es/nocobase-buildin-plugin/index.d.ts +9 -0
  12. package/es/settings-app/SettingsApplication.d.ts +18 -0
  13. package/es/settings-app/SettingsBrand.d.ts +16 -0
  14. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  15. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  16. package/es/settings-app/SettingsGroupNav.d.ts +17 -0
  17. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  18. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  19. package/es/settings-app/SettingsSearch.d.ts +17 -0
  20. package/es/settings-app/SettingsShell.d.ts +10 -0
  21. package/es/settings-app/isSettingsApp.d.ts +18 -0
  22. package/es/settings-app/runtimePaths.d.ts +18 -0
  23. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  24. package/es/settings-app/settingsTheme.d.ts +87 -0
  25. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  26. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  27. package/es/settings-center/groups.d.ts +66 -0
  28. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  29. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  30. package/es/settings-center/utils.d.ts +24 -0
  31. package/es/theme/index.d.ts +1 -0
  32. package/es/theme/itemActive.d.ts +24 -0
  33. package/es/theme/type.d.ts +9 -0
  34. package/lib/index.js +126 -115
  35. package/package.json +7 -7
  36. package/src/__tests__/authRedirect.test.ts +137 -0
  37. package/src/__tests__/browserChecker.test.ts +124 -6
  38. package/src/__tests__/mockSettingsApplication.ts +29 -0
  39. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +44 -8
  40. package/src/__tests__/plugin-manager.test.tsx +7 -6
  41. package/src/__tests__/settings-application.test.ts +164 -0
  42. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  43. package/src/__tests__/settings-center.test.tsx +38 -37
  44. package/src/__tests__/settings-layout-root.test.tsx +260 -0
  45. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  46. package/src/__tests__/settings-shell.test.tsx +200 -0
  47. package/src/acl/ACLProvider.tsx +21 -0
  48. package/src/authRedirect.ts +72 -3
  49. package/src/components/AttachmentUpload.tsx +275 -0
  50. package/src/components/index.ts +1 -0
  51. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +203 -4
  52. package/src/flow/actions/linkageRules.tsx +25 -4
  53. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  54. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  55. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  56. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  57. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  58. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  59. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  60. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  61. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  62. package/src/index.ts +10 -1
  63. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  64. package/src/nocobase-buildin-plugin/index.tsx +99 -71
  65. package/src/settings-app/SettingsApplication.ts +31 -0
  66. package/src/settings-app/SettingsBrand.tsx +108 -0
  67. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  68. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  69. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  70. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  71. package/src/settings-app/SettingsRouterManager.ts +92 -0
  72. package/src/settings-app/SettingsSearch.tsx +238 -0
  73. package/src/settings-app/SettingsShell.tsx +167 -0
  74. package/src/settings-app/isSettingsApp.ts +21 -0
  75. package/src/settings-app/runtimePaths.ts +104 -0
  76. package/src/settings-app/settingsDocumentPath.ts +66 -0
  77. package/src/settings-app/settingsTheme.ts +301 -0
  78. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  79. package/src/settings-center/AdminSettingsLayout.tsx +122 -141
  80. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  81. package/src/settings-center/groups.ts +108 -0
  82. package/src/settings-center/useSettingsGroups.ts +105 -0
  83. package/src/settings-center/useSettingsSearch.ts +173 -0
  84. package/src/settings-center/utils.tsx +50 -1
  85. package/src/theme/index.tsx +1 -0
  86. package/src/theme/itemActive.ts +31 -0
  87. package/src/theme/type.ts +10 -0
@@ -0,0 +1,77 @@
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 { css } from '@emotion/css';
11
+ import { observer } from '@nocobase/flow-engine';
12
+ import { theme as antdTheme } from 'antd';
13
+ import React, { useMemo } from 'react';
14
+ import { useTranslation } from 'react-i18next';
15
+ import { useSystemSettings } from '../../system-settings';
16
+ import type { CustomToken } from '../../../theme';
17
+
18
+ const logoContainerClassName = css`
19
+ height: var(--nb-header-height);
20
+ margin-right: 4px;
21
+ display: inline-flex;
22
+ flex-shrink: 0;
23
+ padding: 0;
24
+ align-items: center;
25
+ `;
26
+
27
+ const fixedWidthClassName = css`
28
+ ${logoContainerClassName}
29
+ width: 168px;
30
+ `;
31
+
32
+ const autoWidthClassName = css`
33
+ ${logoContainerClassName}
34
+ width: auto;
35
+ min-width: 168px;
36
+ `;
37
+
38
+ const logoImageClassName = css`
39
+ object-fit: contain;
40
+ width: 100%;
41
+ height: 100%;
42
+ `;
43
+
44
+ const titleClassName = css`
45
+ width: 100%;
46
+ height: 100%;
47
+ font-weight: 500;
48
+ text-align: center;
49
+ overflow: hidden;
50
+ text-overflow: ellipsis;
51
+ white-space: nowrap;
52
+ `;
53
+
54
+ export const NocoBaseLogo = observer(() => {
55
+ const { token } = antdTheme.useToken();
56
+ const customToken = token as CustomToken;
57
+ const result = useSystemSettings();
58
+ const { t } = useTranslation('lm-collections');
59
+ const title = t(result?.data?.data?.title || 'NocoBase');
60
+ const logoUrl = result?.data?.data?.logo?.url;
61
+ const titleStyle = useMemo<React.CSSProperties>(
62
+ () => ({ color: customToken.colorTextHeaderMenu, fontSize: customToken.fontSizeHeading3 }),
63
+ [customToken.colorTextHeaderMenu, customToken.fontSizeHeading3],
64
+ );
65
+
66
+ const logo = logoUrl ? (
67
+ <img alt={title} className={logoImageClassName} src={logoUrl} />
68
+ ) : (
69
+ <span style={titleStyle} className={titleClassName}>
70
+ {title}
71
+ </span>
72
+ );
73
+
74
+ return <div className={logoUrl ? fixedWidthClassName : autoWidthClassName}>{result?.loading ? null : logo}</div>;
75
+ });
76
+
77
+ NocoBaseLogo.displayName = 'NocoBaseLogo';
@@ -24,7 +24,9 @@ const topbarActionsBarClassName = css`
24
24
  align-items: center;
25
25
  color: var(--nb-topbar-action-color);
26
26
 
27
- .nb-topbar-actions-list {
27
+ .nb-topbar-actions-list,
28
+ .nb-topbar-utility-actions-list,
29
+ .nb-topbar-plugin-settings-action {
28
30
  display: inline-flex;
29
31
  align-items: center;
30
32
  height: 100%;
@@ -127,12 +129,18 @@ const getTopbarActionId = (action: TopbarActionModel) => {
127
129
  return action?.actionId || action?.uid || '';
128
130
  };
129
131
 
132
+ const PLUGIN_SETTINGS_ACTION_ID = 'plugin-settings';
133
+
130
134
  const TopbarActionsContent = React.memo((props: { actions: TopbarActionModel[]; onActionClick?: () => void }) => {
131
135
  const { allow } = useAclSnippets();
132
136
  const { token } = theme.useToken();
133
137
  const customToken = token as CustomToken;
134
138
  const actions = useMemo(() => getVisibleTopbarActions(props.actions, allow), [allow, props.actions]);
135
- const mainActions = actions.filter((action) => getTopbarActionId(action) !== USER_CENTER_ACTION_ID);
139
+ const mainActions = actions.filter((action) => {
140
+ const actionId = getTopbarActionId(action);
141
+ return actionId !== PLUGIN_SETTINGS_ACTION_ID && actionId !== USER_CENTER_ACTION_ID;
142
+ });
143
+ const pluginSettingsAction = actions.find((action) => getTopbarActionId(action) === PLUGIN_SETTINGS_ACTION_ID);
136
144
  const userCenterAction = actions.find((action) => getTopbarActionId(action) === USER_CENTER_ACTION_ID);
137
145
 
138
146
  return (
@@ -153,18 +161,33 @@ const TopbarActionsContent = React.memo((props: { actions: TopbarActionModel[];
153
161
  <ConfigProvider theme={dividerTheme}>
154
162
  <Divider type="vertical" />
155
163
  </ConfigProvider>
156
- <HelpLite />
157
- {userCenterAction ? (
158
- <ErrorBoundary
159
- key={userCenterAction.uid}
160
- FallbackComponent={TopbarActionErrorFallback}
161
- onError={(error) => {
162
- console.error('[NocoBase] Topbar action render failed.', error);
163
- }}
164
- >
165
- <FlowModelRenderer model={userCenterAction} />
166
- </ErrorBoundary>
167
- ) : null}
164
+ <div className="nb-topbar-utility-actions-list">
165
+ {pluginSettingsAction ? (
166
+ <span className="nb-topbar-plugin-settings-action" onClick={props.onActionClick}>
167
+ <ErrorBoundary
168
+ key={pluginSettingsAction.uid}
169
+ FallbackComponent={TopbarActionErrorFallback}
170
+ onError={(error) => {
171
+ console.error('[NocoBase] Topbar action render failed.', error);
172
+ }}
173
+ >
174
+ <FlowModelRenderer model={pluginSettingsAction} />
175
+ </ErrorBoundary>
176
+ </span>
177
+ ) : null}
178
+ <HelpLite />
179
+ {userCenterAction ? (
180
+ <ErrorBoundary
181
+ key={userCenterAction.uid}
182
+ FallbackComponent={TopbarActionErrorFallback}
183
+ onError={(error) => {
184
+ console.error('[NocoBase] Topbar action render failed.', error);
185
+ }}
186
+ >
187
+ <FlowModelRenderer model={userCenterAction} />
188
+ </ErrorBoundary>
189
+ ) : null}
190
+ </div>
168
191
  </div>
169
192
  );
170
193
  });
@@ -136,6 +136,16 @@ describe('TopbarActionsBar helpers', () => {
136
136
  title: 'Plugin manager',
137
137
  path: '/admin/settings/plugin-manager',
138
138
  icon: null,
139
+ sort: -200,
140
+ componentLoader: async () => null,
141
+ },
142
+ {
143
+ key: 'multi-portal',
144
+ name: 'multi-portal',
145
+ title: 'Portal manager',
146
+ path: '/admin/settings/multi-portal',
147
+ icon: null,
148
+ sort: -300,
139
149
  componentLoader: async () => null,
140
150
  },
141
151
  {
@@ -159,13 +169,14 @@ describe('TopbarActionsBar helpers', () => {
159
169
  });
160
170
 
161
171
  expect((items as any[]).map((item) => item.type || item.key)).toEqual([
172
+ 'multi-portal',
162
173
  'plugin-manager',
163
174
  'divider',
164
175
  'system-settings',
165
176
  'divider',
166
177
  'security',
167
178
  ]);
168
- expect((items as any[])[2]).toMatchObject({
179
+ expect((items as any[]).find((item) => item.key === 'system-settings')).toMatchObject({
169
180
  key: 'system-settings',
170
181
  name: 'system-settings',
171
182
  path: '/admin/settings/system-settings',
@@ -191,7 +202,7 @@ describe('TopbarActionsBar helpers', () => {
191
202
  renderSettingsLabel((items as any[])[0].label, '/sales/p1');
192
203
 
193
204
  const link = screen.getByRole('link', { name: 'System settings' });
194
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
205
+ expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
195
206
  expect(link).toHaveAttribute('target', '_blank');
196
207
  expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
197
208
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
@@ -216,7 +227,7 @@ describe('TopbarActionsBar helpers', () => {
216
227
  renderSettingsLabel((items as any[])[0].label, '/sales/p1');
217
228
 
218
229
  const link = screen.getByRole('link', { name: 'Plugin manager' });
219
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/plugin-manager');
230
+ expect(link).toHaveAttribute('href', '/nocobase/settings/plugin-manager');
220
231
  expect(link).toHaveAttribute('target', '_blank');
221
232
  expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
222
233
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
@@ -241,13 +252,13 @@ describe('TopbarActionsBar helpers', () => {
241
252
  renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/crm-amd/ekeisumx1zu');
242
253
 
243
254
  const link = screen.getByRole('link', { name: 'System settings' });
244
- expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/system-settings');
255
+ expect(link).toHaveAttribute('href', '/nocobase/settings/apps/a_9xlild35jir/system-settings');
245
256
  expect(link).toHaveAttribute('target', '_blank');
246
257
  expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
247
258
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
248
259
  });
249
260
 
250
- it('should keep regular admin settings as SPA links inside admin runtime', () => {
261
+ it('should open regular admin settings in the standalone SPA from admin runtime', () => {
251
262
  const items = getTopbarPluginSettingsItems({
252
263
  canManagePlugins: false,
253
264
  t: (key) => key,
@@ -266,11 +277,12 @@ describe('TopbarActionsBar helpers', () => {
266
277
  renderSettingsLabel((items as any[])[0].label, '/admin/settings/routes');
267
278
 
268
279
  const link = screen.getByRole('link', { name: 'Routes' });
269
- expect(link).toHaveAttribute('href', '/admin/settings/routes');
270
- expect(link).not.toHaveAttribute('target', '_blank');
280
+ expect(link).toHaveAttribute('href', '/nocobase/settings/routes');
281
+ expect(link).toHaveAttribute('target', '_blank');
282
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
271
283
  });
272
284
 
273
- it('should keep sub-app admin settings in the current window inside sub-app admin runtime', () => {
285
+ it('should open sub-app settings in the standalone SPA from sub-app admin runtime', () => {
274
286
  const items = getTopbarPluginSettingsItems({
275
287
  canManagePlugins: false,
276
288
  t: (key) => key,
@@ -289,8 +301,9 @@ describe('TopbarActionsBar helpers', () => {
289
301
  renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/admin/settings/routes');
290
302
 
291
303
  const link = screen.getByRole('link', { name: 'Routes' });
292
- expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/routes');
293
- expect(link).not.toHaveAttribute('target', '_blank');
304
+ expect(link).toHaveAttribute('href', '/nocobase/settings/apps/a_9xlild35jir/routes');
305
+ expect(link).toHaveAttribute('target', '_blank');
306
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
294
307
  });
295
308
 
296
309
  it('should not treat admin-like paths as admin runtime', () => {
@@ -312,11 +325,11 @@ describe('TopbarActionsBar helpers', () => {
312
325
  renderSettingsLabel((items as any[])[0].label, '/admin2/foo');
313
326
 
314
327
  const link = screen.getByRole('link', { name: 'System settings' });
315
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
328
+ expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
316
329
  expect(link).toHaveAttribute('target', '_blank');
317
330
  });
318
331
 
319
- it('should not duplicate the basename when building new-tab settings hrefs', () => {
332
+ it('should remove the modern basename when building standalone settings hrefs', () => {
320
333
  const items = getTopbarPluginSettingsItems({
321
334
  canManagePlugins: false,
322
335
  t: (key) => key,
@@ -335,7 +348,30 @@ describe('TopbarActionsBar helpers', () => {
335
348
  renderSettingsLabel((items as any[])[0].label, '/sales/p1');
336
349
 
337
350
  const link = screen.getByRole('link', { name: 'System settings' });
338
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
351
+ expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
352
+ });
353
+
354
+ it('should preserve the new sub-app scope in standalone settings hrefs', () => {
355
+ const items = getTopbarPluginSettingsItems({
356
+ canManagePlugins: false,
357
+ t: (key) => key,
358
+ settings: [
359
+ {
360
+ key: 'system-settings',
361
+ name: 'system-settings',
362
+ title: 'System settings',
363
+ path: '/admin/settings/system-settings',
364
+ icon: null,
365
+ componentLoader: async () => null,
366
+ },
367
+ ] as any,
368
+ });
369
+
370
+ renderSettingsLabel((items as any[])[0].label, '/_app/a_new/admin/settings/system-settings');
371
+
372
+ const link = screen.getByRole('link', { name: 'System settings' });
373
+ expect(link).toHaveAttribute('href', '/nocobase/settings/_app/a_new/system-settings');
374
+ expect(link).toHaveAttribute('target', '_blank');
339
375
  });
340
376
 
341
377
  it('should keep external settings opening in a new tab', () => {
@@ -402,6 +438,34 @@ describe('TopbarActionsBar', () => {
402
438
  vi.restoreAllMocks();
403
439
  });
404
440
 
441
+ it('should group plugin settings with Help and user center', () => {
442
+ render(
443
+ <TopbarActionsBar
444
+ actions={[
445
+ createAction({ uid: 'notification', actionId: 'notification' }),
446
+ createAction({ uid: 'plugin-settings', actionId: 'plugin-settings' }),
447
+ createAction({ uid: 'user-center', actionId: 'user-center' }),
448
+ ]}
449
+ />,
450
+ );
451
+
452
+ const notification = screen.getByTestId('flow-model-notification');
453
+ const pluginSettings = screen.getByTestId('flow-model-plugin-settings');
454
+ const help = screen.getByTestId('help-lite');
455
+ const userCenter = screen.getByTestId('flow-model-user-center');
456
+ const mainGroup = notification.closest('.nb-topbar-actions-list');
457
+ const utilityGroup = pluginSettings.closest('.nb-topbar-utility-actions-list');
458
+
459
+ expect(mainGroup).toContainElement(notification);
460
+ expect(mainGroup).not.toContainElement(pluginSettings);
461
+ expect(utilityGroup).not.toBeNull();
462
+ expect(utilityGroup).toContainElement(pluginSettings);
463
+ expect(utilityGroup).toContainElement(help);
464
+ expect(utilityGroup).toContainElement(userCenter);
465
+ expect(pluginSettings.compareDocumentPosition(help) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
466
+ expect(help.compareDocumentPosition(userCenter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
467
+ });
468
+
405
469
  it('should keep HelpLite rendered when one action fails', () => {
406
470
  vi.spyOn(console, 'error').mockImplementation(() => {});
407
471
 
@@ -7,7 +7,8 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { DefaultStructure, FlowModel, tExpr, useFlowModel } from '@nocobase/flow-engine';
10
+ import { FlowModel, tExpr, useFlowModel } from '@nocobase/flow-engine';
11
+ import type { DefaultStructure, FlowModelOptions } from '@nocobase/flow-engine';
11
12
  import { Icon } from '../../../components';
12
13
  import { Button, Tooltip } from 'antd';
13
14
  import type { ButtonProps } from 'antd/es/button';
@@ -87,8 +88,10 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
87
88
  return null;
88
89
  }
89
90
 
90
- onInit(options: any): void {
91
+ onInit(options: FlowModelOptions<T>): void {
91
92
  super.onInit(options);
93
+ // Action disabled state is computed at runtime and must never be restored from persisted props.
94
+ delete this.props.disabled;
92
95
  this.context.defineProperty('actionName', {
93
96
  get: () => {
94
97
  return this.getAclActionName();
@@ -97,6 +100,12 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
97
100
  });
98
101
  }
99
102
 
103
+ serialize() {
104
+ const data = super.serialize();
105
+ delete data.props.disabled;
106
+ return data;
107
+ }
108
+
100
109
  getInputArgs() {
101
110
  const inputArgs: Record<string, any> = {};
102
111
  const defaultKeys: string[] = [];
@@ -1,3 +1,12 @@
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
+
1
10
  import { render, screen } from '@nocobase/test/client';
2
11
  import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
3
12
  import { App, ConfigProvider } from 'antd';
@@ -35,3 +44,43 @@ describe('ActionModel rendering', () => {
35
44
  expect(screen.getByRole('button', { name: 'Open details' })).toBeInTheDocument();
36
45
  });
37
46
  });
47
+
48
+ describe('ActionModel disabled state persistence', () => {
49
+ it('drops disabled from persisted props during initialization', () => {
50
+ const engine = new FlowEngine();
51
+ engine.registerModels({ NoIconActionModel });
52
+ const model = engine.createModel<NoIconActionModel>({
53
+ use: 'NoIconActionModel',
54
+ props: { title: 'Open details', disabled: true },
55
+ });
56
+
57
+ expect(model.getProps()).not.toHaveProperty('disabled');
58
+ });
59
+
60
+ it('keeps runtime disabled state without serializing it', () => {
61
+ const engine = new FlowEngine();
62
+ engine.registerModels({ NoIconActionModel });
63
+ const model = engine.createModel<NoIconActionModel>({
64
+ use: 'NoIconActionModel',
65
+ props: { title: 'Open details' },
66
+ });
67
+
68
+ model.setProps('disabled', true);
69
+
70
+ expect(model.getProps().disabled).toBe(true);
71
+ expect(model.serialize().props).toEqual({ title: 'Open details' });
72
+ });
73
+
74
+ it('does not serialize fork-local disabled state', () => {
75
+ const engine = new FlowEngine();
76
+ engine.registerModels({ NoIconActionModel });
77
+ const model = engine.createModel<NoIconActionModel>({
78
+ use: 'NoIconActionModel',
79
+ props: { title: 'Open details' },
80
+ });
81
+ const fork = model.createFork({ disabled: true });
82
+
83
+ expect(fork.getProps().disabled).toBe(true);
84
+ expect(fork.serialize().props).toEqual({ title: 'Open details' });
85
+ });
86
+ });
@@ -13,6 +13,22 @@ import JSON5 from 'json5';
13
13
  import React, { useState, useEffect, useRef } from 'react';
14
14
  import { FieldModel } from '../base/FieldModel';
15
15
 
16
+ const jsonValidationRuleMarker = '__jsonSyntaxValidation';
17
+
18
+ interface JsonValidationRule {
19
+ [jsonValidationRuleMarker]: true;
20
+ type: 'any';
21
+ validator?: (_rule: unknown, value: unknown) => Promise<void>;
22
+ }
23
+
24
+ function isRuleObject(rule: unknown): rule is Record<string, unknown> {
25
+ return typeof rule === 'object' && rule !== null;
26
+ }
27
+
28
+ function isJsonValidationRule(rule: unknown): rule is JsonValidationRule {
29
+ return isRuleObject(rule) && rule[jsonValidationRuleMarker] === true;
30
+ }
31
+
16
32
  const jsonCss = css`
17
33
  font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
18
34
  font-size: 14px;
@@ -40,7 +56,7 @@ export const JsonInput = observer((props: any) => {
40
56
  }
41
57
 
42
58
  internalChange.current = false;
43
- }, [value]);
59
+ }, [_JSON, space, value]);
44
60
 
45
61
  // 用户输入时
46
62
  const handleChange = (ev) => {
@@ -91,23 +107,30 @@ JsonFieldModel.registerFlow({
91
107
  key: 'jsonInitSetting',
92
108
  steps: {
93
109
  initValidation: {
94
- handler(ctx, params) {
95
- const rules = ctx.model.parent.props.rules || [];
110
+ handler(ctx) {
111
+ const currentRules = Array.isArray(ctx.model.parent.props.rules) ? ctx.model.parent.props.rules : [];
112
+ const rules = currentRules.filter((rule: unknown) => {
113
+ return (!isRuleObject(rule) || Object.keys(rule).length > 0) && !isJsonValidationRule(rule);
114
+ });
96
115
  // 添加 JSON 语法校验规则
97
116
  rules.push({
98
- validator: (_, value) => {
117
+ // validator 函数序列化后会被丢弃;保留 any 类型可避免持久化规则退化为默认字符串校验。
118
+ type: 'any',
119
+ [jsonValidationRuleMarker]: true,
120
+ validator: (_rule: unknown, value: unknown) => {
99
121
  // 允许空
100
122
  if (value === null || value === undefined || value === '') return Promise.resolve();
101
123
  // 如果已经是对象/数组,直接通过
102
124
  if (typeof value === 'object') return Promise.resolve();
103
125
  try {
104
- JSON.parse(value);
126
+ JSON.parse(String(value));
105
127
  return Promise.resolve();
106
- } catch (err) {
107
- return Promise.reject(ctx.t('Invalid JSON format') + ': ' + err.message);
128
+ } catch (error) {
129
+ const message = error instanceof Error ? error.message : String(error);
130
+ return Promise.reject(ctx.t('Invalid JSON format') + ': ' + message);
108
131
  }
109
132
  },
110
- });
133
+ } satisfies JsonValidationRule);
111
134
  ctx.model.parent.setProps({
112
135
  rules,
113
136
  });
@@ -0,0 +1,82 @@
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 { FlowEngine, FlowModel } from '@nocobase/flow-engine';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { JsonFieldModel } from '../JsonFieldModel';
13
+
14
+ interface ValidationRule {
15
+ required?: boolean;
16
+ type?: string;
17
+ validator?: (_rule: unknown, value: unknown) => Promise<void>;
18
+ }
19
+
20
+ function createModels(rules: ValidationRule[]) {
21
+ const engine = new FlowEngine();
22
+ engine.registerModels({ JsonFieldModel });
23
+ const parent = engine.createModel<FlowModel<{ subModels: { field: JsonFieldModel } }>>({
24
+ use: FlowModel,
25
+ uid: 'json-form-item',
26
+ props: { rules },
27
+ subModels: {
28
+ field: {
29
+ use: JsonFieldModel,
30
+ uid: 'json-field',
31
+ },
32
+ },
33
+ });
34
+
35
+ return { field: parent.subModels.field, parent };
36
+ }
37
+
38
+ function initializeJsonValidation(field: JsonFieldModel) {
39
+ const step = field.getFlow('jsonInitSetting')?.steps.initValidation;
40
+ if (!step) {
41
+ throw new Error('JSON validation step is not registered');
42
+ }
43
+ step.handler(field.context, {});
44
+ }
45
+
46
+ describe('JsonFieldModel validation', () => {
47
+ it('replaces legacy empty rules with one serializable JSON validation rule', async () => {
48
+ const { field, parent } = createModels([{}, { required: true }]);
49
+
50
+ initializeJsonValidation(field);
51
+
52
+ const rules = parent.props.rules as ValidationRule[];
53
+ expect(rules).toHaveLength(2);
54
+ expect(rules[0]).toEqual({ required: true });
55
+ expect(rules[1]).toMatchObject({ type: 'any' });
56
+ expect(rules[1].validator).toBeTypeOf('function');
57
+ if (!rules[1].validator) {
58
+ throw new Error('JSON validator is missing');
59
+ }
60
+ await expect(rules[1].validator({}, { name: 'NocoBase' })).resolves.toBeUndefined();
61
+ await expect(rules[1].validator({}, '{"name":')).rejects.toContain('Invalid JSON format');
62
+
63
+ const persistedRules = JSON.parse(JSON.stringify(parent.serialize())).props.rules as ValidationRule[];
64
+ expect(persistedRules).toHaveLength(2);
65
+ expect(persistedRules[1]).toMatchObject({ type: 'any' });
66
+ expect(persistedRules[1].validator).toBeUndefined();
67
+ });
68
+
69
+ it('re-registers the runtime validator without duplicating its persisted placeholder', () => {
70
+ const { field, parent } = createModels([{}]);
71
+ initializeJsonValidation(field);
72
+ const persistedRules = JSON.parse(JSON.stringify(parent.serialize())).props.rules as ValidationRule[];
73
+ parent.setProps({ rules: persistedRules });
74
+
75
+ initializeJsonValidation(field);
76
+
77
+ const rules = parent.props.rules as ValidationRule[];
78
+ expect(rules).toHaveLength(1);
79
+ expect(rules[0]).toMatchObject({ type: 'any' });
80
+ expect(rules[0].validator).toBeTypeOf('function');
81
+ });
82
+ });