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

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 (121) hide show
  1. package/es/PluginSettingsManager.d.ts +33 -0
  2. package/es/RouteRepository.d.ts +21 -6
  3. package/es/components/form/JsonTextArea.d.ts +2 -1
  4. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  5. package/es/components/form/index.d.ts +1 -0
  6. package/es/components/index.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  8. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  9. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  10. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +4 -0
  12. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  13. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  14. package/es/flow/models/base/GridModel.d.ts +1 -1
  15. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  16. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  17. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  18. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
  19. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  20. package/es/flow-compat/FieldValidation.d.ts +2 -1
  21. package/es/index.d.ts +2 -1
  22. package/es/index.mjs +145 -119
  23. package/lib/index.js +148 -122
  24. package/package.json +7 -7
  25. package/src/PluginSettingsManager.ts +53 -0
  26. package/src/RouteRepository.ts +126 -24
  27. package/src/__tests__/PluginSettingsManager.test.ts +13 -0
  28. package/src/__tests__/RouteRepository.test.ts +216 -0
  29. package/src/__tests__/exports.test.ts +16 -0
  30. package/src/__tests__/plugin-manager.test.tsx +44 -2
  31. package/src/__tests__/settings-center.test.tsx +40 -1
  32. package/src/components/form/JsonTextArea.tsx +21 -11
  33. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  34. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  35. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  36. package/src/components/form/index.tsx +1 -0
  37. package/src/components/index.ts +1 -0
  38. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  39. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +58 -0
  40. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  41. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  42. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
  43. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
  44. package/src/flow/actions/customVariable.tsx +1 -2
  45. package/src/flow/actions/dataScopeFilter.ts +10 -0
  46. package/src/flow/actions/linkageRules.tsx +122 -34
  47. package/src/flow/actions/runjs.tsx +2 -14
  48. package/src/flow/actions/validation.tsx +62 -30
  49. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +33 -10
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  51. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  52. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  53. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  55. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  56. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +21 -8
  57. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  58. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  59. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  60. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  61. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  62. package/src/flow/components/DefaultValue.tsx +1 -2
  63. package/src/flow/components/FieldAssignRulesEditor.tsx +128 -21
  64. package/src/flow/components/FlowRoute.tsx +56 -11
  65. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +508 -4
  66. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +151 -3
  67. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  68. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  69. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  70. package/src/flow/components/fieldAssignOptions.ts +155 -27
  71. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  72. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  73. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  74. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  75. package/src/flow/models/base/ActionModel.tsx +12 -1
  76. package/src/flow/models/base/GridModel.tsx +38 -7
  77. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  78. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
  79. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  80. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  81. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  82. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +31 -3
  83. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  84. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  85. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  86. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  87. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  88. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  89. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
  90. package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
  91. package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
  92. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  93. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  94. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  95. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +1 -1
  96. package/src/flow/models/blocks/table/TableBlockModel.tsx +13 -3
  97. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +54 -0
  98. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  99. package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
  100. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
  101. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
  102. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
  103. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
  104. package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
  105. package/src/flow/models/fields/InputFieldModel.tsx +14 -22
  106. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  107. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  108. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  109. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
  110. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  111. package/src/flow-compat/FieldValidation.tsx +122 -60
  112. package/src/flow-compat/Popover.tsx +43 -4
  113. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  114. package/src/index.ts +8 -1
  115. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  116. package/src/nocobase-buildin-plugin/index.tsx +0 -2
  117. package/src/settings-center/AdminSettingsLayout.tsx +3 -0
  118. package/src/settings-center/SystemSettingsPage.tsx +0 -1
  119. package/src/settings-center/plugin-manager/PluginCard.tsx +2 -2
  120. package/src/settings-center/plugin-manager/index.tsx +3 -0
  121. package/src/settings-center/utils.tsx +0 -6
@@ -28,6 +28,13 @@ type FlowRouteGuardState = {
28
28
 
29
29
  export type LegacyPageBehavior = 'redirect' | 'notFound' | 'bridge';
30
30
 
31
+ type FlowRouteLayoutContext = {
32
+ authCheck?: boolean;
33
+ routePath?: string;
34
+ routeName?: string;
35
+ uid?: string;
36
+ };
37
+
31
38
  export type FlowRouteProps = {
32
39
  pageUid?: string;
33
40
  active?: boolean;
@@ -38,7 +45,7 @@ export type FlowRouteProps = {
38
45
  const getDefaultAdminLayoutModel = (flowEngine: FlowEngine) =>
39
46
  getAdminLayoutModel<AdminLayoutModel>(flowEngine, { required: true });
40
47
 
41
- const getDefaultLayoutModel = (flowEngine: FlowEngine, contextLayout?: any) => {
48
+ const getDefaultLayoutModel = (flowEngine: FlowEngine, contextLayout?: FlowRouteLayoutContext) => {
42
49
  const layout = contextLayout || flowEngine.context.layout;
43
50
 
44
51
  if (layout?.uid) {
@@ -48,7 +55,10 @@ const getDefaultLayoutModel = (flowEngine: FlowEngine, contextLayout?: any) => {
48
55
  return getDefaultAdminLayoutModel(flowEngine);
49
56
  };
50
57
 
51
- const getDefaultLegacyPageBehavior = (flowEngine: FlowEngine, contextLayout?: any): LegacyPageBehavior => {
58
+ const getDefaultLegacyPageBehavior = (
59
+ flowEngine: FlowEngine,
60
+ contextLayout?: FlowRouteLayoutContext,
61
+ ): LegacyPageBehavior => {
52
62
  const layout = contextLayout || flowEngine.context.layout;
53
63
 
54
64
  if (layout?.routeName && layout.routeName !== 'admin') {
@@ -58,6 +68,8 @@ const getDefaultLegacyPageBehavior = (flowEngine: FlowEngine, contextLayout?: an
58
68
  return 'redirect';
59
69
  };
60
70
 
71
+ const shouldRequireAccessibleRoute = (contextLayout?: FlowRouteLayoutContext) => contextLayout?.authCheck !== false;
72
+
61
73
  const hasFlowModel = async (flowEngine: FlowEngine, pageUid: string) => {
62
74
  if (flowEngine.getModel(pageUid)) {
63
75
  return true;
@@ -148,18 +160,46 @@ const BridgeFlowRoute = ({
148
160
  * @throws {Error} 当缺少 `route.params.name` 时抛出异常
149
161
  */
150
162
  const FlowRoute = (props: FlowRouteProps = {}) => {
163
+ const {
164
+ active,
165
+ getLayoutModel: getLayoutModelProp,
166
+ legacyPageBehavior: legacyPageBehaviorProp,
167
+ pageUid: pageUidProp,
168
+ } = props;
151
169
  const flowEngine = useFlowEngine();
152
- const flowContext = useFlowContext<any>();
170
+ const flowContext = useFlowContext<{ layout?: FlowRouteLayoutContext }>();
153
171
  const contextLayout = flowContext?.layout;
172
+ const propsLayoutModel = useMemo(() => getLayoutModelProp?.(flowEngine), [flowEngine, getLayoutModelProp]);
173
+ const rawRouteLayout = contextLayout || propsLayoutModel?.layout;
174
+ const routeLayoutUid = rawRouteLayout?.uid;
175
+ const routeLayoutRouteName = rawRouteLayout?.routeName;
176
+ const routeLayoutRoutePath = rawRouteLayout?.routePath;
177
+ const routeLayoutAuthCheck = rawRouteLayout?.authCheck;
178
+ const routeLayout = useMemo(() => {
179
+ if (
180
+ !routeLayoutUid &&
181
+ !routeLayoutRouteName &&
182
+ !routeLayoutRoutePath &&
183
+ typeof routeLayoutAuthCheck === 'undefined'
184
+ ) {
185
+ return undefined;
186
+ }
187
+ return {
188
+ authCheck: routeLayoutAuthCheck,
189
+ routeName: routeLayoutRouteName,
190
+ routePath: routeLayoutRoutePath,
191
+ uid: routeLayoutUid,
192
+ };
193
+ }, [routeLayoutAuthCheck, routeLayoutRouteName, routeLayoutRoutePath, routeLayoutUid]);
154
194
  const getLayoutModel = useMemo(
155
- () => props.getLayoutModel || ((engine: FlowEngine) => getDefaultLayoutModel(engine, contextLayout)),
156
- [contextLayout, props.getLayoutModel],
195
+ () => getLayoutModelProp || ((engine: FlowEngine) => getDefaultLayoutModel(engine, routeLayout)),
196
+ [getLayoutModelProp, routeLayout],
157
197
  );
158
- const legacyPageBehavior = props.legacyPageBehavior || getDefaultLegacyPageBehavior(flowEngine, contextLayout);
198
+ const legacyPageBehavior = legacyPageBehaviorProp || getDefaultLegacyPageBehavior(flowEngine, routeLayout);
159
199
  const app = useApp();
160
200
  const routeRepository = flowEngine.context.routeRepository;
161
201
  const params = useParams();
162
- const pageUid = props.pageUid || params?.name;
202
+ const pageUid = pageUidProp || params?.name;
163
203
  const skipRouteRepositoryCheck = !routeRepository;
164
204
  const [guardState, setGuardState] = useState<FlowRouteGuardState>({
165
205
  pending: true,
@@ -196,6 +236,11 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
196
236
  }
197
237
 
198
238
  const route = skipRouteRepositoryCheck ? undefined : routeRepository?.getRouteBySchemaUid?.(pageUid);
239
+ if (!route && !skipRouteRepositoryCheck && shouldRequireAccessibleRoute(routeLayout)) {
240
+ setGuardState({ pending: false, allowBridge: false, notFound: true });
241
+ return;
242
+ }
243
+
199
244
  if (!route && legacyPageBehavior === 'notFound') {
200
245
  const flowModelExists = await hasFlowModel(flowEngine, pageUid);
201
246
  if (active && requestId === requestIdRef.current) {
@@ -245,12 +290,12 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
245
290
  }
246
291
  };
247
292
 
248
- void run();
293
+ run();
249
294
 
250
295
  return () => {
251
296
  active = false;
252
297
  };
253
- }, [app, flowEngine, legacyPageBehavior, pageUid, routeRepository, skipRouteRepositoryCheck]);
298
+ }, [app, flowEngine, legacyPageBehavior, pageUid, routeLayout, routeRepository, skipRouteRepositoryCheck]);
254
299
 
255
300
  const content = useMemo(() => {
256
301
  if (guardState.pending) {
@@ -265,8 +310,8 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
265
310
  return null;
266
311
  }
267
312
 
268
- return <BridgeFlowRoute pageUid={pageUid} active={props.active} getLayoutModel={getLayoutModel} />;
269
- }, [getLayoutModel, guardState.allowBridge, guardState.notFound, guardState.pending, pageUid, props.active]);
313
+ return <BridgeFlowRoute pageUid={pageUid} active={active} getLayoutModel={getLayoutModel} />;
314
+ }, [active, getLayoutModel, guardState.allowBridge, guardState.notFound, guardState.pending, pageUid]);
270
315
 
271
316
  return content;
272
317
  };
@@ -285,6 +285,433 @@ describe('FieldAssignRulesEditor', () => {
285
285
  });
286
286
  });
287
287
 
288
+ it('preloads full association children when configured options already contain partial children', async () => {
289
+ const nameField = { name: 'name', title: 'Name', interface: 'input' };
290
+ const nicknameField = { name: 'nickname', title: 'Nickname', interface: 'input' };
291
+ const profileCollection = {
292
+ getField: (name: string) => {
293
+ if (name === 'name') return nameField;
294
+ if (name === 'nickname') return nicknameField;
295
+ return null;
296
+ },
297
+ getFields: vi.fn(() => [nameField, nicknameField]),
298
+ };
299
+ const profileField = {
300
+ name: 'profile',
301
+ title: 'Profile',
302
+ type: 'belongsTo',
303
+ interface: 'm2o',
304
+ target: 'profiles',
305
+ targetCollection: profileCollection,
306
+ };
307
+ const rootCollection = {
308
+ getField: (name: string) => (name === 'profile' ? profileField : null),
309
+ getFields: () => [profileField],
310
+ };
311
+ const fieldOptions = [
312
+ {
313
+ label: 'Profile',
314
+ value: 'profile',
315
+ isLeaf: false,
316
+ children: [{ label: 'Name', value: 'name', isLeaf: true }],
317
+ },
318
+ ];
319
+ const value: FieldAssignRuleItem[] = [
320
+ {
321
+ key: 'rule-profile-nickname',
322
+ enable: true,
323
+ targetPath: 'profile.nickname',
324
+ mode: 'assign',
325
+ },
326
+ ];
327
+
328
+ render(
329
+ wrap(
330
+ <FieldAssignRulesEditor
331
+ t={t}
332
+ fieldOptions={fieldOptions}
333
+ rootCollection={rootCollection}
334
+ value={value}
335
+ showCondition={false}
336
+ />,
337
+ ),
338
+ );
339
+
340
+ await waitFor(() => {
341
+ expect(profileCollection.getFields).toHaveBeenCalled();
342
+ });
343
+ });
344
+
345
+ it('keeps configured child paths expandable when target collection cannot be resolved', async () => {
346
+ const configuredParent = {
347
+ label: 'Legacy profile',
348
+ value: 'legacyProfile',
349
+ isLeaf: false,
350
+ children: [{ label: 'Nickname', value: 'nickname', isLeaf: true }],
351
+ };
352
+ const rootCollection = {
353
+ getField: vi.fn(() => null),
354
+ getFields: () => [],
355
+ };
356
+ const value: FieldAssignRuleItem[] = [
357
+ {
358
+ key: 'rule-legacy-profile-nickname',
359
+ enable: true,
360
+ targetPath: 'legacyProfile.nickname',
361
+ mode: 'assign',
362
+ },
363
+ ];
364
+
365
+ render(
366
+ wrap(
367
+ <FieldAssignRulesEditor
368
+ t={t}
369
+ fieldOptions={[configuredParent]}
370
+ rootCollection={rootCollection}
371
+ value={value}
372
+ showCondition={false}
373
+ />,
374
+ ),
375
+ );
376
+
377
+ await waitFor(() => {
378
+ expect(rootCollection.getField).toHaveBeenCalled();
379
+ expect(configuredParent.isLeaf).toBe(false);
380
+ });
381
+ });
382
+
383
+ it('preloads the latest fieldOptions after props switch', async () => {
384
+ const nameField = { name: 'name', title: 'Name', interface: 'input' };
385
+ const nicknameField = { name: 'nickname', title: 'Nickname', interface: 'input' };
386
+ const profileCollection = {
387
+ getField: (name: string) => {
388
+ if (name === 'name') return nameField;
389
+ if (name === 'nickname') return nicknameField;
390
+ return null;
391
+ },
392
+ getFields: vi.fn(() => [nameField, nicknameField]),
393
+ };
394
+ const profileField = {
395
+ name: 'profile',
396
+ title: 'Profile',
397
+ type: 'belongsTo',
398
+ interface: 'm2o',
399
+ target: 'profiles',
400
+ targetCollection: profileCollection,
401
+ };
402
+ const rootCollection = {
403
+ getField: (name: string) => (name === 'profile' ? profileField : null),
404
+ getFields: () => [profileField],
405
+ };
406
+ const oldConfiguredParent = {
407
+ label: 'Profile',
408
+ value: 'profile',
409
+ isLeaf: false,
410
+ children: [{ label: 'Name', value: 'name', isLeaf: true }],
411
+ };
412
+ const latestConfiguredParent = {
413
+ label: 'Profile',
414
+ value: 'profile',
415
+ isLeaf: false,
416
+ children: [{ label: 'Name', value: 'name', isLeaf: true }],
417
+ };
418
+ const value: FieldAssignRuleItem[] = [
419
+ {
420
+ key: 'rule-profile-nickname',
421
+ enable: true,
422
+ targetPath: 'profile.nickname',
423
+ mode: 'assign',
424
+ },
425
+ ];
426
+
427
+ const { container, rerender } = render(
428
+ wrap(
429
+ <FieldAssignRulesEditor
430
+ t={t}
431
+ fieldOptions={[oldConfiguredParent]}
432
+ rootCollection={rootCollection}
433
+ value={[]}
434
+ showCondition={false}
435
+ />,
436
+ ),
437
+ );
438
+
439
+ rerender(
440
+ wrap(
441
+ <FieldAssignRulesEditor
442
+ t={t}
443
+ fieldOptions={[latestConfiguredParent]}
444
+ rootCollection={rootCollection}
445
+ value={value}
446
+ showCondition={false}
447
+ />,
448
+ ),
449
+ );
450
+
451
+ await waitFor(() => {
452
+ expect(profileCollection.getFields).toHaveBeenCalled();
453
+ });
454
+ const selector = container.querySelector('.ant-select-selector') as HTMLElement | null;
455
+ expect(selector).not.toBeNull();
456
+ await userEvent.click(selector as HTMLElement);
457
+ expect(await screen.findByText('Nickname')).toBeInTheDocument();
458
+ });
459
+
460
+ it('does not mutate fieldOptions props while preloading children', async () => {
461
+ const nameField = { name: 'name', title: 'Name', interface: 'input' };
462
+ const nicknameField = { name: 'nickname', title: 'Nickname', interface: 'input' };
463
+ const profileCollection = {
464
+ getField: (name: string) => {
465
+ if (name === 'name') return nameField;
466
+ if (name === 'nickname') return nicknameField;
467
+ return null;
468
+ },
469
+ getFields: vi.fn(() => [nameField, nicknameField]),
470
+ };
471
+ const profileField = {
472
+ name: 'profile',
473
+ title: 'Profile',
474
+ type: 'belongsTo',
475
+ interface: 'm2o',
476
+ target: 'profiles',
477
+ targetCollection: profileCollection,
478
+ };
479
+ const rootCollection = {
480
+ getField: (name: string) => (name === 'profile' ? profileField : null),
481
+ getFields: () => [profileField],
482
+ };
483
+ const configuredParent = {
484
+ label: 'Profile',
485
+ value: 'profile',
486
+ isLeaf: false,
487
+ children: [{ label: 'Name', value: 'name', isLeaf: true }],
488
+ };
489
+ const value: FieldAssignRuleItem[] = [
490
+ {
491
+ key: 'rule-profile-nickname',
492
+ enable: true,
493
+ targetPath: 'profile.nickname',
494
+ mode: 'assign',
495
+ },
496
+ ];
497
+
498
+ render(
499
+ wrap(
500
+ <FieldAssignRulesEditor
501
+ t={t}
502
+ fieldOptions={[configuredParent]}
503
+ rootCollection={rootCollection}
504
+ value={value}
505
+ showCondition={false}
506
+ />,
507
+ ),
508
+ );
509
+
510
+ await waitFor(() => {
511
+ expect(profileCollection.getFields).toHaveBeenCalled();
512
+ });
513
+ expect(configuredParent.children.some((child) => child.value === 'nickname')).toBe(false);
514
+ });
515
+
516
+ it('retries preload when rootCollection becomes resolvable with same fieldOptions', async () => {
517
+ const nicknameField = { name: 'nickname', title: 'Nickname', interface: 'input' };
518
+ const profileCollection = {
519
+ getField: (name: string) => (name === 'nickname' ? nicknameField : null),
520
+ getFields: vi.fn(() => [nicknameField]),
521
+ };
522
+ const profileField = {
523
+ name: 'profile',
524
+ title: 'Profile',
525
+ type: 'belongsTo',
526
+ interface: 'm2o',
527
+ target: 'profiles',
528
+ targetCollection: profileCollection,
529
+ };
530
+ const unresolvedCollection = {
531
+ getField: () => null,
532
+ getFields: () => [],
533
+ };
534
+ const resolvedCollection = {
535
+ getField: (name: string) => (name === 'profile' ? profileField : null),
536
+ getFields: () => [profileField],
537
+ };
538
+ const configuredParent = {
539
+ label: 'Profile',
540
+ value: 'profile',
541
+ isLeaf: false,
542
+ children: [{ label: 'Name', value: 'name', isLeaf: true }],
543
+ };
544
+ const value: FieldAssignRuleItem[] = [
545
+ {
546
+ key: 'rule-profile-nickname',
547
+ enable: true,
548
+ targetPath: 'profile.nickname',
549
+ mode: 'assign',
550
+ },
551
+ ];
552
+
553
+ const { rerender } = render(
554
+ wrap(
555
+ <FieldAssignRulesEditor
556
+ t={t}
557
+ fieldOptions={[configuredParent]}
558
+ rootCollection={unresolvedCollection}
559
+ value={value}
560
+ showCondition={false}
561
+ />,
562
+ ),
563
+ );
564
+
565
+ rerender(
566
+ wrap(
567
+ <FieldAssignRulesEditor
568
+ t={t}
569
+ fieldOptions={[configuredParent]}
570
+ rootCollection={resolvedCollection}
571
+ value={value}
572
+ showCondition={false}
573
+ />,
574
+ ),
575
+ );
576
+
577
+ await waitFor(() => {
578
+ expect(profileCollection.getFields).toHaveBeenCalled();
579
+ });
580
+ });
581
+
582
+ it('handles preload errors without leaving unhandled rejections', async () => {
583
+ const loadError = new Error('failed to load fields');
584
+ const profileCollection = {
585
+ getFields: vi.fn(() => {
586
+ throw loadError;
587
+ }),
588
+ };
589
+ const profileField = {
590
+ name: 'profile',
591
+ title: 'Profile',
592
+ type: 'belongsTo',
593
+ interface: 'm2o',
594
+ target: 'profiles',
595
+ targetCollection: profileCollection,
596
+ };
597
+ const rootCollection = {
598
+ getField: (name: string) => (name === 'profile' ? profileField : null),
599
+ getFields: () => [profileField],
600
+ };
601
+ const configuredParent = {
602
+ label: 'Profile',
603
+ value: 'profile',
604
+ isLeaf: false,
605
+ };
606
+ const value: FieldAssignRuleItem[] = [
607
+ {
608
+ key: 'rule-profile-nickname',
609
+ enable: true,
610
+ targetPath: 'profile.nickname',
611
+ mode: 'assign',
612
+ },
613
+ ];
614
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
615
+
616
+ render(
617
+ wrap(
618
+ <FieldAssignRulesEditor
619
+ t={t}
620
+ fieldOptions={[configuredParent]}
621
+ rootCollection={rootCollection}
622
+ value={value}
623
+ showCondition={false}
624
+ />,
625
+ ),
626
+ );
627
+
628
+ try {
629
+ await waitFor(() => {
630
+ expect(warnSpy).toHaveBeenCalledWith('[FieldAssignRulesEditor] Failed to preload cascader path', loadError);
631
+ });
632
+ } finally {
633
+ warnSpy.mockRestore();
634
+ }
635
+ });
636
+
637
+ it('keeps loaded cascader children when translation function identity changes', async () => {
638
+ const nameField = { name: 'name', title: 'Name', interface: 'input' };
639
+ const nicknameField = { name: 'nickname', title: 'Nickname', interface: 'input' };
640
+ const profileCollection = {
641
+ getField: (name: string) => {
642
+ if (name === 'name') return nameField;
643
+ if (name === 'nickname') return nicknameField;
644
+ return null;
645
+ },
646
+ getFields: vi.fn(() => [nameField, nicknameField]),
647
+ };
648
+ const profileField = {
649
+ name: 'profile',
650
+ title: 'Profile',
651
+ type: 'belongsTo',
652
+ interface: 'm2o',
653
+ target: 'profiles',
654
+ targetCollection: profileCollection,
655
+ };
656
+ const rootCollection = {
657
+ getField: (name: string) => (name === 'profile' ? profileField : null),
658
+ getFields: () => [profileField],
659
+ };
660
+ const fieldOptions = [
661
+ {
662
+ label: 'Profile',
663
+ value: 'profile',
664
+ isLeaf: false,
665
+ },
666
+ ];
667
+ const value: FieldAssignRuleItem[] = [
668
+ {
669
+ key: 'rule-profile',
670
+ enable: true,
671
+ targetPath: 'profile',
672
+ mode: 'assign',
673
+ },
674
+ ];
675
+
676
+ const { container, rerender } = render(
677
+ wrap(
678
+ <FieldAssignRulesEditor
679
+ t={(key) => key}
680
+ fieldOptions={fieldOptions}
681
+ rootCollection={rootCollection}
682
+ value={value}
683
+ showCondition={false}
684
+ />,
685
+ ),
686
+ );
687
+
688
+ const selector = container.querySelector('.ant-select-selector') as HTMLElement | null;
689
+ expect(selector).not.toBeNull();
690
+ await userEvent.click(selector as HTMLElement);
691
+ const profileOption = (await screen.findAllByText('Profile')).find((item) =>
692
+ item.closest('.ant-cascader-menu-item'),
693
+ );
694
+ expect(profileOption).toBeTruthy();
695
+ await userEvent.click(profileOption as HTMLElement);
696
+ expect(await screen.findByText('Nickname')).toBeInTheDocument();
697
+ await userEvent.keyboard('{Escape}');
698
+
699
+ rerender(
700
+ wrap(
701
+ <FieldAssignRulesEditor
702
+ t={(key) => key}
703
+ fieldOptions={fieldOptions}
704
+ rootCollection={rootCollection}
705
+ value={value}
706
+ showCondition={false}
707
+ />,
708
+ ),
709
+ );
710
+
711
+ await userEvent.click(selector as HTMLElement);
712
+ expect(await screen.findByText('Nickname')).toBeInTheDocument();
713
+ });
714
+
288
715
  it('saves selected title field into current assign rule without syncing', async () => {
289
716
  const { rootCollection, value } = createAssociationFixture();
290
717
  const onChange = vi.fn();
@@ -795,15 +1222,23 @@ describe('FieldAssignRulesEditor', () => {
795
1222
  );
796
1223
 
797
1224
  expect(screen.getByLabelText('Default value')).toBeInTheDocument();
1225
+ expect(screen.getByLabelText('Override value')).toBeInTheDocument();
798
1226
  expect(screen.getByLabelText('Fixed value')).toBeInTheDocument();
799
1227
  expect(screen.getByLabelText('Fixed value')).toBeChecked();
800
1228
  await userEvent.click(screen.getByLabelText('Default value'));
801
1229
  await waitFor(() => {
802
1230
  expect(onChange).toHaveBeenCalled();
803
1231
  });
804
-
805
- const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
1232
+ let lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
806
1233
  expect(lastCall?.[0]?.mode).toBe('default');
1234
+
1235
+ onChange.mockClear();
1236
+ await userEvent.click(screen.getByLabelText('Override value'));
1237
+ await waitFor(() => {
1238
+ expect(onChange).toHaveBeenCalled();
1239
+ });
1240
+ lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
1241
+ expect(lastCall?.[0]?.mode).toBe('override');
807
1242
  });
808
1243
 
809
1244
  it('shows assignment mode tooltips for each option', async () => {
@@ -821,7 +1256,7 @@ describe('FieldAssignRulesEditor', () => {
821
1256
  );
822
1257
 
823
1258
  const questionIcons = Array.from(container.querySelectorAll('.anticon-question-circle'));
824
- expect(questionIcons).toHaveLength(2);
1259
+ expect(questionIcons).toHaveLength(3);
825
1260
 
826
1261
  await userEvent.hover(questionIcons[0] as Element);
827
1262
  await waitFor(() => {
@@ -835,11 +1270,80 @@ describe('FieldAssignRulesEditor', () => {
835
1270
  await userEvent.unhover(questionIcons[0] as Element);
836
1271
 
837
1272
  await userEvent.hover(questionIcons[1] as Element);
1273
+ await waitFor(() => {
1274
+ expect(screen.getByText('Overrides existing values until the user edits it.')).toBeInTheDocument();
1275
+ });
1276
+
1277
+ await userEvent.unhover(questionIcons[1] as Element);
1278
+
1279
+ await userEvent.hover(questionIcons[2] as Element);
838
1280
  await waitFor(() => {
839
1281
  expect(screen.getByText('A system-set value. Read-only.')).toBeInTheDocument();
840
1282
  });
841
1283
  });
842
1284
 
1285
+ it('honors allowed assignment modes', () => {
1286
+ const value: FieldAssignRuleItem[] = [
1287
+ {
1288
+ key: '1',
1289
+ enable: true,
1290
+ targetPath: 'title',
1291
+ mode: 'override',
1292
+ },
1293
+ ];
1294
+
1295
+ render(
1296
+ wrap(
1297
+ <FieldAssignRulesEditor
1298
+ t={t}
1299
+ fieldOptions={[]}
1300
+ value={value}
1301
+ allowedModes={['default', 'assign']}
1302
+ showCondition={false}
1303
+ showEnable={false}
1304
+ />,
1305
+ ),
1306
+ );
1307
+
1308
+ expect(screen.getByLabelText('Default value')).toBeInTheDocument();
1309
+ expect(screen.getByLabelText('Fixed value')).toBeInTheDocument();
1310
+ expect(screen.queryByLabelText('Override value')).toBeNull();
1311
+ expect(screen.getByLabelText('Fixed value')).toBeChecked();
1312
+ });
1313
+
1314
+ it('normalizes disallowed assignment mode before emitting changes', async () => {
1315
+ const onChange = vi.fn();
1316
+ const value: FieldAssignRuleItem[] = [
1317
+ {
1318
+ key: '1',
1319
+ enable: true,
1320
+ targetPath: 'title',
1321
+ mode: 'override',
1322
+ },
1323
+ ];
1324
+
1325
+ render(
1326
+ wrap(
1327
+ <FieldAssignRulesEditor
1328
+ t={t}
1329
+ fieldOptions={[]}
1330
+ value={value}
1331
+ onChange={onChange}
1332
+ allowedModes={['default', 'assign']}
1333
+ showCondition={false}
1334
+ />,
1335
+ ),
1336
+ );
1337
+
1338
+ await userEvent.click(screen.getByRole('switch'));
1339
+
1340
+ await waitFor(() => {
1341
+ expect(onChange).toHaveBeenCalled();
1342
+ });
1343
+ const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
1344
+ expect(lastCall?.[0]?.mode).toBe('assign');
1345
+ });
1346
+
843
1347
  it('merges extra item tree into base item while keeping order', () => {
844
1348
  const base: MetaTreeNode[] = [
845
1349
  { name: 'formValues', title: 'Current form', type: 'object', paths: ['formValues'] },
@@ -897,7 +1401,7 @@ describe('FieldAssignRulesEditor', () => {
897
1401
 
898
1402
  const itemNode = merged.find((node) => node.name === 'item');
899
1403
  expect(itemNode).toBeTruthy();
900
- const parent = (itemNode.children as MetaTreeNode[]).find((node) => node.name === 'parentItem');
1404
+ const parent = ((itemNode?.children as MetaTreeNode[]) || []).find((node) => node.name === 'parentItem');
901
1405
  expect(parent).toBeTruthy();
902
1406
 
903
1407
  const nestedParent = ((parent?.children as MetaTreeNode[]) || []).find((node) => node.name === 'parentItem');