@nocobase/client-v2 2.2.0-beta.5 → 2.2.0-beta.6

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 (109) hide show
  1. package/es/RouteRepository.d.ts +21 -6
  2. package/es/components/form/JsonTextArea.d.ts +2 -1
  3. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  4. package/es/components/form/index.d.ts +1 -0
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  8. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  9. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  10. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +4 -0
  11. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  12. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  13. package/es/flow/models/base/GridModel.d.ts +1 -1
  14. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  15. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  16. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  17. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
  18. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  19. package/es/flow-compat/FieldValidation.d.ts +2 -1
  20. package/es/index.d.ts +2 -1
  21. package/es/index.mjs +143 -117
  22. package/lib/index.js +148 -122
  23. package/package.json +7 -7
  24. package/src/RouteRepository.ts +126 -24
  25. package/src/__tests__/RouteRepository.test.ts +216 -0
  26. package/src/__tests__/exports.test.ts +16 -0
  27. package/src/__tests__/settings-center.test.tsx +33 -0
  28. package/src/components/form/JsonTextArea.tsx +21 -11
  29. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  30. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  31. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  32. package/src/components/form/index.tsx +1 -0
  33. package/src/components/index.ts +1 -0
  34. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  35. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  36. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  37. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
  38. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
  39. package/src/flow/actions/customVariable.tsx +1 -2
  40. package/src/flow/actions/linkageRules.tsx +122 -34
  41. package/src/flow/actions/runjs.tsx +2 -14
  42. package/src/flow/actions/validation.tsx +62 -30
  43. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +33 -10
  44. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  45. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  46. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  47. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  48. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  49. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +21 -8
  51. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  52. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  53. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  54. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  55. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  56. package/src/flow/components/DefaultValue.tsx +1 -2
  57. package/src/flow/components/FieldAssignRulesEditor.tsx +62 -12
  58. package/src/flow/components/FlowRoute.tsx +56 -11
  59. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  60. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  61. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  62. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  63. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  64. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  65. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  66. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  67. package/src/flow/models/base/ActionModel.tsx +12 -1
  68. package/src/flow/models/base/GridModel.tsx +38 -7
  69. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  70. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
  71. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  72. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  73. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  74. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +31 -3
  75. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  76. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  77. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  78. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  79. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  80. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  81. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
  82. package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
  83. package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
  84. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  85. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  86. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  87. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +1 -1
  88. package/src/flow/models/blocks/table/TableBlockModel.tsx +13 -3
  89. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +54 -0
  90. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  91. package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
  92. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
  93. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
  94. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
  95. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
  96. package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
  97. package/src/flow/models/fields/InputFieldModel.tsx +14 -22
  98. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  99. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  100. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  101. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
  102. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  103. package/src/flow-compat/FieldValidation.tsx +122 -60
  104. package/src/flow-compat/Popover.tsx +43 -4
  105. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  106. package/src/index.ts +8 -1
  107. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  108. package/src/settings-center/AdminSettingsLayout.tsx +3 -0
  109. package/src/settings-center/plugin-manager/index.tsx +3 -0
@@ -318,6 +318,50 @@ describe('AdminLayoutModel menu items', () => {
318
318
  expect(route.children[1]._model).toBe(adminLayoutModel.subModels.menuItems?.[1]);
319
319
  });
320
320
 
321
+ it('should generate ProLayout route tree under a custom admin layout route path', () => {
322
+ const adminLayoutModel = engine.createModel<AdminLayoutModel>({
323
+ uid: 'admin2-layout-model',
324
+ use: AdminLayoutModel,
325
+ props: {
326
+ layout: {
327
+ routeName: 'admin2',
328
+ routePath: '/admin2',
329
+ uid: 'admin2-layout-model',
330
+ layoutModelClass: 'AdminLayoutModel',
331
+ },
332
+ },
333
+ });
334
+
335
+ adminLayoutModel.syncMenuRoutes([
336
+ {
337
+ id: 1,
338
+ title: 'Group',
339
+ type: NocoBaseDesktopRouteType.group,
340
+ children: [
341
+ {
342
+ id: 11,
343
+ title: 'Page 1',
344
+ schemaUid: 'page-1',
345
+ type: NocoBaseDesktopRouteType.flowPage,
346
+ },
347
+ ],
348
+ },
349
+ ]);
350
+
351
+ const route = adminLayoutModel.toProLayoutRoute({
352
+ designable: false,
353
+ isMobile: false,
354
+ t: (title) => title,
355
+ });
356
+
357
+ expect(route.children[0].path).toBe('/admin2/1');
358
+ expect(route.children[0].redirect).toBe('/admin2/page-1');
359
+ expect(route.children[0]._runtimePath).toBe('/apps/demo/v2/admin2/page-1');
360
+ expect(route.children[0].routes?.[0].path).toBe('/admin2/page-1');
361
+ expect(route.children[0].routes?.[0].redirect).toBe('/admin2/page-1');
362
+ expect(route.children[0].routes?.[0]._runtimePath).toBe('/apps/demo/v2/admin2/page-1');
363
+ });
364
+
321
365
  it('should filter legacy page menu routes but keep empty groups in v2 admin layout', () => {
322
366
  const adminLayoutModel = engine.createModel<AdminLayoutModel>({
323
367
  uid: 'admin-layout-model',
@@ -1128,6 +1172,46 @@ describe('AdminLayoutModel menu items', () => {
1128
1172
  expect(adminLayoutModel.menuRouteRefreshVersion).toBe(refreshBefore + 1);
1129
1173
  });
1130
1174
 
1175
+ it('should refresh the owning admin layout when hiding custom layout menu routes dynamically', () => {
1176
+ const defaultAdminLayoutModel = engine.createModel<AdminLayoutModel>({
1177
+ uid: ADMIN_LAYOUT_MODEL_UID,
1178
+ use: AdminLayoutModel,
1179
+ });
1180
+ const customAdminLayoutModel = engine.createModel<AdminLayoutModel>({
1181
+ uid: 'custom-admin-layout-model',
1182
+ use: AdminLayoutModel,
1183
+ props: {
1184
+ layout: {
1185
+ routeName: 'admin2',
1186
+ routePath: '/admin2',
1187
+ rootRouteName: 'admin2',
1188
+ uid: 'custom-admin-layout-model',
1189
+ layoutModelClass: 'AdminLayoutModel',
1190
+ rootPageModelClass: 'RootPageModel',
1191
+ childPageModelClass: 'ChildPageModel',
1192
+ authCheck: true,
1193
+ },
1194
+ },
1195
+ });
1196
+ const model = engine.createModel<AdminLayoutMenuItemModel>({
1197
+ uid: 'menu-item-custom-layout-dynamic-hidden',
1198
+ use: AdminLayoutMenuItemModel,
1199
+ props: {
1200
+ route: createRoute(),
1201
+ },
1202
+ });
1203
+
1204
+ model.setParent(customAdminLayoutModel);
1205
+
1206
+ const defaultRefreshBefore = defaultAdminLayoutModel.menuRouteRefreshVersion;
1207
+ const customRefreshBefore = customAdminLayoutModel.menuRouteRefreshVersion;
1208
+
1209
+ model.setHidden(true);
1210
+
1211
+ expect(customAdminLayoutModel.menuRouteRefreshVersion).toBe(customRefreshBefore + 1);
1212
+ expect(defaultAdminLayoutModel.menuRouteRefreshVersion).toBe(defaultRefreshBefore);
1213
+ });
1214
+
1131
1215
  it('should render hidden menu item with opacity and keep original title in config mode', () => {
1132
1216
  const model = engine.createModel<AdminLayoutMenuItemModel>({
1133
1217
  uid: 'menu-item-hidden-in-config',
@@ -2222,6 +2306,21 @@ describe('AdminLayoutModel menu items', () => {
2222
2306
  );
2223
2307
  });
2224
2308
 
2309
+ it('should include router basename when opening same-origin links in a new window', async () => {
2310
+ await openAdminLayoutMenuLink({
2311
+ context: engine.context as any,
2312
+ href: '/admin2/page',
2313
+ params: [{ name: 'from', value: 'admin' }],
2314
+ openInNewWindow: true,
2315
+ isMobile: false,
2316
+ closeMobileMenu: vi.fn(),
2317
+ navigate: navigateMock,
2318
+ basenameOfCurrentRouter: '/v',
2319
+ });
2320
+
2321
+ expect(window.open).toHaveBeenCalledWith('/v/admin2/page?from=admin', '_blank', 'noopener,noreferrer');
2322
+ });
2323
+
2225
2324
  it('should resolve sibling move options for non-group drag target', () => {
2226
2325
  const activeModel = engine.createModel<AdminLayoutMenuItemModel>({
2227
2326
  uid: 'drag-source-page',
@@ -8,12 +8,20 @@
8
8
  */
9
9
 
10
10
  import React from 'react';
11
- import { render, screen } from '@testing-library/react';
11
+ import { fireEvent, render, screen } from '@testing-library/react';
12
+ import { MemoryRouter } from 'react-router-dom';
12
13
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
14
 
14
- const { allowMock, flowModelRendererSpy } = vi.hoisted(() => {
15
+ const { allowMock, appMock, flowModelRendererSpy } = vi.hoisted(() => {
15
16
  return {
16
17
  allowMock: vi.fn(),
18
+ appMock: {
19
+ current: {
20
+ router: {
21
+ getBasename: () => '/nocobase/v',
22
+ },
23
+ },
24
+ },
17
25
  flowModelRendererSpy: vi.fn(),
18
26
  };
19
27
  });
@@ -32,6 +40,12 @@ vi.mock('../HelpLite', () => {
32
40
  };
33
41
  });
34
42
 
43
+ vi.mock('../../../../hooks/useApp', () => {
44
+ return {
45
+ useApp: () => appMock.current,
46
+ };
47
+ });
48
+
35
49
  vi.mock('@nocobase/flow-engine', async (importOriginal) => {
36
50
  const actual = await importOriginal<typeof import('@nocobase/flow-engine')>();
37
51
  return {
@@ -63,10 +77,19 @@ const createAction = (options: Record<string, any>) => {
63
77
  } as any;
64
78
  };
65
79
 
80
+ const renderSettingsLabel = (label: React.ReactNode, pathname: string) => {
81
+ return render(<MemoryRouter initialEntries={[pathname]}>{label}</MemoryRouter>);
82
+ };
83
+
66
84
  describe('TopbarActionsBar helpers', () => {
67
85
  beforeEach(() => {
68
86
  allowMock.mockReset();
69
87
  flowModelRendererSpy.mockClear();
88
+ appMock.current = {
89
+ router: {
90
+ getBasename: () => '/nocobase/v',
91
+ },
92
+ };
70
93
  });
71
94
 
72
95
  afterEach(() => {
@@ -149,6 +172,147 @@ describe('TopbarActionsBar helpers', () => {
149
172
  });
150
173
  });
151
174
 
175
+ it('should open regular admin settings in a new tab outside admin runtime', () => {
176
+ const items = getTopbarPluginSettingsItems({
177
+ canManagePlugins: false,
178
+ t: (key) => key,
179
+ settings: [
180
+ {
181
+ key: 'system-settings',
182
+ name: 'system-settings',
183
+ title: 'System settings',
184
+ path: '/admin/settings/system-settings',
185
+ icon: null,
186
+ componentLoader: async () => null,
187
+ },
188
+ ] as any,
189
+ });
190
+
191
+ renderSettingsLabel((items as any[])[0].label, '/sales/p1');
192
+
193
+ const link = screen.getByRole('link', { name: 'System settings' });
194
+ expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
195
+ expect(link).toHaveAttribute('target', '_blank');
196
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
197
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
198
+ });
199
+
200
+ it('should open plugin manager in a new tab outside admin runtime', () => {
201
+ const items = getTopbarPluginSettingsItems({
202
+ canManagePlugins: true,
203
+ t: (key) => key,
204
+ settings: [
205
+ {
206
+ key: 'plugin-manager',
207
+ name: 'plugin-manager',
208
+ title: 'Plugin manager',
209
+ path: '/admin/settings/plugin-manager',
210
+ icon: null,
211
+ componentLoader: async () => null,
212
+ },
213
+ ] as any,
214
+ });
215
+
216
+ renderSettingsLabel((items as any[])[0].label, '/sales/p1');
217
+
218
+ const link = screen.getByRole('link', { name: 'Plugin manager' });
219
+ expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/plugin-manager');
220
+ expect(link).toHaveAttribute('target', '_blank');
221
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
222
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
223
+ });
224
+
225
+ it('should keep regular admin settings as SPA links inside admin runtime', () => {
226
+ const items = getTopbarPluginSettingsItems({
227
+ canManagePlugins: false,
228
+ t: (key) => key,
229
+ settings: [
230
+ {
231
+ key: 'routes',
232
+ name: 'routes',
233
+ title: 'Routes',
234
+ path: '/admin/settings/routes',
235
+ icon: null,
236
+ componentLoader: async () => null,
237
+ },
238
+ ] as any,
239
+ });
240
+
241
+ renderSettingsLabel((items as any[])[0].label, '/admin/settings/routes');
242
+
243
+ const link = screen.getByRole('link', { name: 'Routes' });
244
+ expect(link).toHaveAttribute('href', '/admin/settings/routes');
245
+ expect(link).not.toHaveAttribute('target', '_blank');
246
+ });
247
+
248
+ it('should not treat admin-like paths as admin runtime', () => {
249
+ const items = getTopbarPluginSettingsItems({
250
+ canManagePlugins: false,
251
+ t: (key) => key,
252
+ settings: [
253
+ {
254
+ key: 'system-settings',
255
+ name: 'system-settings',
256
+ title: 'System settings',
257
+ path: '/admin/settings/system-settings',
258
+ icon: null,
259
+ componentLoader: async () => null,
260
+ },
261
+ ] as any,
262
+ });
263
+
264
+ renderSettingsLabel((items as any[])[0].label, '/admin2/foo');
265
+
266
+ const link = screen.getByRole('link', { name: 'System settings' });
267
+ expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
268
+ expect(link).toHaveAttribute('target', '_blank');
269
+ });
270
+
271
+ it('should not duplicate the basename when building new-tab settings hrefs', () => {
272
+ const items = getTopbarPluginSettingsItems({
273
+ canManagePlugins: false,
274
+ t: (key) => key,
275
+ settings: [
276
+ {
277
+ key: 'system-settings',
278
+ name: 'system-settings',
279
+ title: 'System settings',
280
+ path: '/nocobase/v/admin/settings/system-settings',
281
+ icon: null,
282
+ componentLoader: async () => null,
283
+ },
284
+ ] as any,
285
+ });
286
+
287
+ renderSettingsLabel((items as any[])[0].label, '/sales/p1');
288
+
289
+ const link = screen.getByRole('link', { name: 'System settings' });
290
+ expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
291
+ });
292
+
293
+ it('should keep external settings opening in a new tab', () => {
294
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
295
+ const items = getTopbarPluginSettingsItems({
296
+ canManagePlugins: false,
297
+ t: (key) => key,
298
+ settings: [
299
+ {
300
+ key: 'docs',
301
+ name: 'docs',
302
+ title: 'Docs',
303
+ link: 'https://www.nocobase.com/docs',
304
+ icon: null,
305
+ componentLoader: async () => null,
306
+ },
307
+ ] as any,
308
+ });
309
+
310
+ renderSettingsLabel((items as any[])[0].label, '/sales/p1');
311
+ fireEvent.click(screen.getByText('Docs'));
312
+
313
+ expect(openSpy).toHaveBeenCalledWith('https://www.nocobase.com/docs', '_blank', 'noopener,noreferrer');
314
+ });
315
+
152
316
  it('should return empty dropdown items when plugin manager and settings are both unavailable', () => {
153
317
  const items = getTopbarPluginSettingsItems({
154
318
  canManagePlugins: false,
@@ -52,6 +52,26 @@ describe('resolveAdminRouteRuntimeTarget', () => {
52
52
  });
53
53
  });
54
54
 
55
+ it('should resolve flowPage under a custom admin layout route path', () => {
56
+ expect(
57
+ resolveAdminRouteRuntimeTarget({
58
+ app,
59
+ layout: {
60
+ routePath: '/admin2',
61
+ },
62
+ route: {
63
+ type: NocoBaseDesktopRouteType.flowPage,
64
+ schemaUid: 'flow-page-1',
65
+ },
66
+ }),
67
+ ).toEqual({
68
+ runtimePath: '/nocobase/v2/admin2/flow-page-1',
69
+ navigationMode: 'spa',
70
+ isLegacy: false,
71
+ reason: 'ok',
72
+ });
73
+ });
74
+
55
75
  it('should mark page unsupported in v2 admin runtime', () => {
56
76
  expect(
57
77
  resolveAdminRouteRuntimeTarget({
@@ -10,6 +10,7 @@
10
10
  import { getModernClientPrefix, getV2EffectiveBasePath } from '../../../authRedirect';
11
11
  import type { BaseApplication } from '../../../BaseApplication';
12
12
  import { NocoBaseDesktopRouteType, type NocoBaseDesktopRoute } from '../../../flow-compat';
13
+ import type { LayoutDefinition } from '../../../layout-manager/types';
13
14
 
14
15
  export type AdminRouteNavigationMode = 'spa' | 'document';
15
16
  export type AdminRouteRuntimeTargetReason =
@@ -32,6 +33,8 @@ type LocationLike = {
32
33
  hash?: string;
33
34
  };
34
35
 
36
+ export type AdminLayoutRoutePathLike = Pick<LayoutDefinition, 'routePath'>;
37
+
35
38
  type ResolveAdminRouteRuntimeTargetOptions = {
36
39
  app: Pick<BaseApplication<any>, 'getPublicPath'> & {
37
40
  router?: {
@@ -39,12 +42,14 @@ type ResolveAdminRouteRuntimeTargetOptions = {
39
42
  };
40
43
  };
41
44
  route?: NocoBaseDesktopRoute;
45
+ layout?: AdminLayoutRoutePathLike;
42
46
  location?: LocationLike;
43
47
  preserveLocationState?: boolean;
44
48
  log?: (message?: any, ...optionalParams: any[]) => void;
45
49
  };
46
50
 
47
51
  const LOG_PREFIX = '[NocoBase] Admin route runtime target:';
52
+ export const DEFAULT_ADMIN_LAYOUT_ROUTE_PATH = '/admin';
48
53
 
49
54
  const EMPTY_TARGET: AdminRouteRuntimeTarget = {
50
55
  runtimePath: null,
@@ -61,6 +66,25 @@ function normalizeRootRelativePath(pathname: string) {
61
66
  return normalized;
62
67
  }
63
68
 
69
+ export function getAdminLayoutRoutePath(layout?: AdminLayoutRoutePathLike | null) {
70
+ const routePath = typeof layout?.routePath === 'string' ? layout.routePath.trim() : '';
71
+ return routePath.startsWith('/') ? normalizeRootRelativePath(routePath) : DEFAULT_ADMIN_LAYOUT_ROUTE_PATH;
72
+ }
73
+
74
+ export function joinAdminLayoutRoutePath(
75
+ layout: AdminLayoutRoutePathLike | null | undefined,
76
+ pathname?: string | number | null,
77
+ ) {
78
+ const layoutRoutePath = getAdminLayoutRoutePath(layout);
79
+ const routePath = pathname == null ? '' : String(pathname).replace(/^\/+/, '');
80
+
81
+ if (!routePath) {
82
+ return layoutRoutePath;
83
+ }
84
+
85
+ return normalizeRootRelativePath(`${layoutRoutePath}/${routePath}`);
86
+ }
87
+
64
88
  function normalizePublicPath(value = '/') {
65
89
  const normalized = normalizeRootRelativePath(value || '/');
66
90
  return normalized.endsWith('/') ? normalized : `${normalized}/`;
@@ -104,8 +128,12 @@ function joinRootRelativePath(basePath: string, pathname: string) {
104
128
  return normalizeRootRelativePath(`${normalizedBasePath}${normalizedPathname.slice(1)}`);
105
129
  }
106
130
 
107
- function getV2AdminPath(app: ResolveAdminRouteRuntimeTargetOptions['app'], schemaUid: string) {
108
- return joinRootRelativePath(getV2EffectiveBasePath(app), `/admin/${schemaUid}`);
131
+ function getV2AdminPath(
132
+ app: ResolveAdminRouteRuntimeTargetOptions['app'],
133
+ schemaUid: string,
134
+ layout?: AdminLayoutRoutePathLike,
135
+ ) {
136
+ return joinRootRelativePath(getV2EffectiveBasePath(app), joinAdminLayoutRoutePath(layout, schemaUid));
109
137
  }
110
138
 
111
139
  function appendLocationState(pathname: string, location?: LocationLike) {
@@ -212,7 +240,7 @@ function resolvePageRuntimeTarget(
212
240
 
213
241
  if (route.type === NocoBaseDesktopRouteType.flowPage) {
214
242
  return {
215
- runtimePath: getV2AdminPath(app, route.schemaUid),
243
+ runtimePath: getV2AdminPath(app, route.schemaUid, options.layout),
216
244
  navigationMode: 'spa' as const,
217
245
  isLegacy: false,
218
246
  reason: 'ok' as const,
@@ -229,8 +257,8 @@ function resolvePageRuntimeTarget(
229
257
  return {
230
258
  runtimePath:
231
259
  preserveLocationState && location
232
- ? appendLocationState(getV2AdminPath(app, route.schemaUid), location)
233
- : getV2AdminPath(app, route.schemaUid),
260
+ ? appendLocationState(getV2AdminPath(app, route.schemaUid, options.layout), location)
261
+ : getV2AdminPath(app, route.schemaUid, options.layout),
234
262
  navigationMode: 'spa' as const,
235
263
  isLegacy: false,
236
264
  reason: 'ok' as const,
@@ -18,7 +18,6 @@ import {
18
18
  parseValueToPath,
19
19
  isRunJSValue,
20
20
  normalizeRunJSValue,
21
- runjsWithSafeGlobals,
22
21
  useFlowContext,
23
22
  extractPropertyPath,
24
23
  FlowModel,
@@ -240,7 +239,7 @@ export const DefaultValue = connect((props: Props) => {
240
239
  if (isRunJSValue(out)) {
241
240
  try {
242
241
  const { code, version } = normalizeRunJSValue(out);
243
- const ret = await runjsWithSafeGlobals(model?.context, code, { version });
242
+ const ret = await model?.context?.runjs(code, undefined, { version });
244
243
  out = ret?.success ? ret.value : undefined;
245
244
  } catch {
246
245
  out = undefined;
@@ -28,7 +28,7 @@ import { buildFieldAssignCascaderOptionsFromCollection, type FieldAssignCascader
28
28
  import { isRunJSValue, isVariableExpression, type MetaTreeNode } from '@nocobase/flow-engine';
29
29
  import { isToManyAssociationField } from '../internal/utils/modelUtils';
30
30
 
31
- export type AssignMode = 'default' | 'assign';
31
+ export type AssignMode = 'default' | 'assign' | 'override';
32
32
 
33
33
  type CollectionFieldLike = {
34
34
  name?: unknown;
@@ -89,6 +89,8 @@ export interface FieldAssignRulesEditorProps {
89
89
  defaultMode?: AssignMode;
90
90
  /** 固定 mode:用于“仅默认值/仅赋值”的场景 */
91
91
  fixedMode?: AssignMode;
92
+ /** 可选 mode 列表;fixedMode 优先级更高 */
93
+ allowedModes?: AssignMode[];
92
94
  /** 是否显示 condition */
93
95
  showCondition?: boolean;
94
96
  /** 是否显示 enable 开关 */
@@ -109,6 +111,23 @@ export interface FieldAssignRulesEditorProps {
109
111
  maxAssociationFieldDepth?: number;
110
112
  }
111
113
 
114
+ const ALL_ASSIGN_MODES: AssignMode[] = ['default', 'override', 'assign'];
115
+
116
+ function isAssignMode(mode: unknown): mode is AssignMode {
117
+ return mode === 'default' || mode === 'override' || mode === 'assign';
118
+ }
119
+
120
+ function normalizeAssignModes(modes?: AssignMode[]): AssignMode[] {
121
+ const source = Array.isArray(modes) && modes.length ? modes : ALL_ASSIGN_MODES;
122
+ const out: AssignMode[] = [];
123
+ for (const mode of source) {
124
+ if (!isAssignMode(mode)) continue;
125
+ if (out.includes(mode)) continue;
126
+ out.push(mode);
127
+ }
128
+ return out.length ? out : ALL_ASSIGN_MODES;
129
+ }
130
+
112
131
  export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (props) => {
113
132
  const {
114
133
  t,
@@ -118,6 +137,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
118
137
  onChange,
119
138
  defaultMode = 'assign',
120
139
  fixedMode,
140
+ allowedModes: rawAllowedModes,
121
141
  showCondition = true,
122
142
  showEnable = true,
123
143
  showValueEditorWhenNoField = false,
@@ -129,6 +149,28 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
129
149
  } = props;
130
150
 
131
151
  const value = React.useMemo(() => (Array.isArray(rawValue) ? rawValue : []), [rawValue]);
152
+ const allowedModes = React.useMemo(
153
+ () => normalizeAssignModes(fixedMode ? [fixedMode] : rawAllowedModes),
154
+ [fixedMode, rawAllowedModes],
155
+ );
156
+ const getDefaultMode = React.useCallback((): AssignMode => {
157
+ if (fixedMode) return fixedMode;
158
+ if (isAssignMode(defaultMode) && allowedModes.includes(defaultMode)) return defaultMode;
159
+ return allowedModes[0] || 'assign';
160
+ }, [allowedModes, defaultMode, fixedMode]);
161
+ const normalizeItemMode = React.useCallback(
162
+ (item: FieldAssignRuleItem): FieldAssignRuleItem => {
163
+ const mode = isAssignMode(item?.mode) && allowedModes.includes(item.mode) ? item.mode : getDefaultMode();
164
+ return item?.mode === mode ? item : { ...item, mode };
165
+ },
166
+ [allowedModes, getDefaultMode],
167
+ );
168
+ const emitChange = React.useCallback(
169
+ (next: FieldAssignRuleItem[]) => {
170
+ onChange?.(next.map((item) => normalizeItemMode(item)));
171
+ },
172
+ [normalizeItemMode, onChange],
173
+ );
132
174
  const [cascaderOptions, setCascaderOptions] = React.useState<FieldAssignCascaderOption[]>(() =>
133
175
  Array.isArray(fieldOptions) ? (fieldOptions as FieldAssignCascaderOption[]) : [],
134
176
  );
@@ -312,7 +354,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
312
354
 
313
355
  const patchItem = (index: number, patch: Partial<FieldAssignRuleItem>) => {
314
356
  const next = value.map((it, i) => (i === index ? { ...it, ...patch } : it));
315
- onChange?.(next);
357
+ emitChange(next);
316
358
  };
317
359
 
318
360
  const removeItem = (index: number) => {
@@ -334,7 +376,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
334
376
  setSyncingRuleKey(null);
335
377
  }
336
378
  const next = value.filter((_, i) => i !== index);
337
- onChange?.(next);
379
+ emitChange(next);
338
380
  };
339
381
 
340
382
  const moveItem = (index: number, direction: 'up' | 'down') => {
@@ -343,7 +385,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
343
385
  if (targetIndex < 0 || targetIndex >= next.length) return;
344
386
  const [item] = next.splice(index, 1);
345
387
  next.splice(targetIndex, 0, item);
346
- onChange?.(next);
388
+ emitChange(next);
347
389
  };
348
390
 
349
391
  const addItem = () => {
@@ -352,29 +394,33 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
352
394
  {
353
395
  key: uid(),
354
396
  enable: true,
355
- mode: fixedMode || defaultMode,
397
+ mode: getDefaultMode(),
356
398
  condition: { logic: '$and', items: [] },
357
399
  targetPath: undefined,
358
400
  value: undefined,
359
401
  },
360
402
  ];
361
- onChange?.(next);
403
+ emitChange(next);
362
404
  };
363
405
 
364
406
  const getEffectiveMode = (item: FieldAssignRuleItem): AssignMode => {
365
407
  if (fixedMode) return fixedMode;
366
- return item?.mode === 'default' ? 'default' : 'assign';
408
+ if (isAssignMode(item?.mode) && allowedModes.includes(item.mode)) return item.mode;
409
+ return getDefaultMode();
367
410
  };
368
411
 
369
412
  const renderAssignModeLabel = React.useCallback(
370
413
  (mode: AssignMode) => {
371
- const modeText = mode === 'default' ? t('Default value') : t('Fixed value');
414
+ const modeText =
415
+ mode === 'default' ? t('Default value') : mode === 'override' ? t('Override value') : t('Fixed value');
372
416
  const modeHelpText =
373
417
  mode === 'default'
374
418
  ? t(
375
419
  'A pre-filled value. Editable, for new entries only, and won’t affect existing data (including empty values).',
376
420
  )
377
- : t('A system-set value. Read-only.');
421
+ : mode === 'override'
422
+ ? t('Overrides existing values until the user edits it.')
423
+ : t('A system-set value. Read-only.');
378
424
 
379
425
  const preventModeToggle = (e: React.MouseEvent<HTMLElement>) => {
380
426
  e.preventDefault();
@@ -627,7 +673,8 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
627
673
 
628
674
  const renderPanelHeader = (item: FieldAssignRuleItem, index: number) => {
629
675
  const mode = getEffectiveMode(item);
630
- const modeText = mode === 'default' ? t('Default value') : t('Fixed value');
676
+ const modeText =
677
+ mode === 'default' ? t('Default value') : mode === 'override' ? t('Override value') : t('Fixed value');
631
678
  const fieldLabel = getFieldLabel(item.targetPath);
632
679
  const title = fieldLabel ? String(fieldLabel) : t('Please select field');
633
680
 
@@ -935,8 +982,11 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
935
982
  onChange={(event) => patchItem(index, { mode: event.target.value as AssignMode })}
936
983
  >
937
984
  <Space size={16}>
938
- <Radio value="default">{renderAssignModeLabel('default')}</Radio>
939
- <Radio value="assign">{renderAssignModeLabel('assign')}</Radio>
985
+ {allowedModes.map((allowedMode) => (
986
+ <Radio key={allowedMode} value={allowedMode}>
987
+ {renderAssignModeLabel(allowedMode)}
988
+ </Radio>
989
+ ))}
940
990
  </Space>
941
991
  </Radio.Group>
942
992
  </div>