@nocobase/client-v2 2.1.0-beta.43 → 2.1.0-beta.45

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 (91) hide show
  1. package/es/BaseApplication.d.ts +9 -0
  2. package/es/authRedirect.d.ts +12 -0
  3. package/es/collection-manager/filter-operators/index.d.ts +1 -0
  4. package/es/components/form/filter/CollectionFilterItem.d.ts +5 -1
  5. package/es/components/form/filter/FilterValueInput.d.ts +5 -0
  6. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +11 -0
  8. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  9. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +3 -2
  10. package/es/flow/components/ConditionBuilder.d.ts +1 -0
  11. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  12. package/es/flow/components/FieldAssignValueInput.d.ts +1 -0
  13. package/es/flow/components/fieldAssignOptions.d.ts +10 -0
  14. package/es/flow/components/filter/LinkageFilterItem.d.ts +1 -0
  15. package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
  16. package/es/flow/components/filter/metaTreeAssociationDepth.d.ts +13 -0
  17. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +26 -3
  18. package/es/flow/models/base/AssociationFieldGroupModel.d.ts +2 -33
  19. package/es/flow/models/blocks/filter-manager/utils.d.ts +23 -1
  20. package/es/flow/models/blocks/table/TableColumnModel.d.ts +2 -0
  21. package/es/index.mjs +179 -101
  22. package/lib/index.js +187 -109
  23. package/package.json +7 -7
  24. package/src/BaseApplication.tsx +13 -0
  25. package/src/__tests__/browserChecker.test.ts +103 -0
  26. package/src/__tests__/dataSourceRuntime.test.tsx +22 -1
  27. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +5 -0
  28. package/src/authRedirect.ts +43 -0
  29. package/src/collection-field-interface/CollectionFieldInterface.ts +14 -7
  30. package/src/collection-manager/filter-operators/index.ts +29 -2
  31. package/src/components/form/filter/CollectionFilterItem.tsx +10 -2
  32. package/src/components/form/filter/FilterValueInput.tsx +27 -2
  33. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +39 -0
  34. package/src/flow/__tests__/FlowRoute.test.tsx +8 -1
  35. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +369 -2
  36. package/src/flow/actions/dataScope.tsx +8 -1
  37. package/src/flow/actions/linkageRules.tsx +56 -1
  38. package/src/flow/actions/setTargetDataScope.tsx +1 -1
  39. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +31 -3
  40. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +15 -1
  41. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +139 -0
  42. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +75 -1
  43. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  44. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +11 -0
  45. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -4
  46. package/src/flow/admin-shell/admin-layout/useApplications.tsx +34 -1
  47. package/src/flow/common/Markdown/Edit.tsx +32 -4
  48. package/src/flow/components/ConditionBuilder.tsx +12 -2
  49. package/src/flow/components/DefaultValue.tsx +14 -8
  50. package/src/flow/components/FieldAssignRulesEditor.tsx +37 -46
  51. package/src/flow/components/FieldAssignValueInput.tsx +14 -14
  52. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +112 -1
  53. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +71 -1
  54. package/src/flow/components/fieldAssignOptions.ts +23 -6
  55. package/src/flow/components/filter/LinkageFilterItem.tsx +26 -12
  56. package/src/flow/components/filter/VariableFilterItem.tsx +32 -13
  57. package/src/flow/components/filter/__tests__/fieldsToOptions.test.ts +48 -0
  58. package/src/flow/components/filter/__tests__/metaTreeAssociationDepth.test.ts +60 -0
  59. package/src/flow/components/filter/fieldsToOptions.ts +9 -4
  60. package/src/flow/components/filter/metaTreeAssociationDepth.ts +69 -0
  61. package/src/flow/internal/utils/operatorSchemaHelper.ts +112 -5
  62. package/src/flow/models/actions/FilterActionModel.tsx +15 -3
  63. package/src/flow/models/base/AssociationFieldGroupModel.tsx +20 -3
  64. package/src/flow/models/base/GridModel.tsx +1 -1
  65. package/src/flow/models/base/PageModel/ChildPageModel.tsx +5 -1
  66. package/src/flow/models/base/PageModel/__tests__/ChildPageModel.test.ts +12 -0
  67. package/src/flow/models/base/__tests__/AssociationFieldGroupModel.test.ts +86 -0
  68. package/src/flow/models/base/__tests__/GridModel.render.test.tsx +53 -0
  69. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +11 -0
  70. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +14 -12
  71. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +6 -1
  72. package/src/flow/models/blocks/filter-manager/__tests__/getDefaultOperator.test.ts +35 -1
  73. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +148 -0
  74. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/defaultOperator.test.tsx +85 -0
  75. package/src/flow/models/blocks/filter-manager/flow-actions/connectFields.tsx +48 -6
  76. package/src/flow/models/blocks/filter-manager/flow-actions/customizeFilterRender.tsx +14 -44
  77. package/src/flow/models/blocks/filter-manager/flow-actions/defaultOperator.tsx +3 -5
  78. package/src/flow/models/blocks/filter-manager/flow-actions/operatorComponentProps.tsx +2 -12
  79. package/src/flow/models/blocks/filter-manager/utils.ts +143 -4
  80. package/src/flow/models/blocks/form/FormAssociationItemModel.tsx +9 -0
  81. package/src/flow/models/blocks/form/FormItemModel.tsx +9 -0
  82. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  83. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +27 -2
  84. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +29 -0
  85. package/src/flow/models/blocks/form/value-runtime/runtime.ts +5 -1
  86. package/src/flow/models/blocks/table/TableColumnModel.tsx +15 -5
  87. package/src/flow/models/blocks/table/TableCustomColumnModel.tsx +2 -2
  88. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +18 -0
  89. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +6 -7
  90. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -3
  91. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +70 -0
@@ -24,7 +24,7 @@ import type { CascaderProps, CollapseProps } from 'antd';
24
24
  import React from 'react';
25
25
  import { ConditionBuilder } from './ConditionBuilder';
26
26
  import { FieldAssignValueInput } from './FieldAssignValueInput';
27
- import type { FieldAssignCascaderOption } from './fieldAssignOptions';
27
+ import { buildFieldAssignCascaderOptionsFromCollection, type FieldAssignCascaderOption } from './fieldAssignOptions';
28
28
  import { isRunJSValue, isVariableExpression, type MetaTreeNode } from '@nocobase/flow-engine';
29
29
  import { isToManyAssociationField } from '../internal/utils/modelUtils';
30
30
 
@@ -36,6 +36,7 @@ type CollectionFieldLike = {
36
36
  type?: unknown;
37
37
  interface?: unknown;
38
38
  uiSchema?: unknown;
39
+ target?: unknown;
39
40
  targetKey?: unknown;
40
41
  targetCollectionTitleFieldName?: unknown;
41
42
  targetCollection?: any;
@@ -105,6 +106,7 @@ export interface FieldAssignRulesEditorProps {
105
106
  onSyncAssociationTitleField?: (params: SyncAssociationTitleFieldParams) => Promise<void> | void;
106
107
  /** 在日期字段下启用“日期变量替换 Constant 位”。 */
107
108
  enableDateVariableAsConstant?: boolean;
109
+ maxAssociationFieldDepth?: number;
108
110
  }
109
111
 
110
112
  export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (props) => {
@@ -123,9 +125,10 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
123
125
  isTitleFieldCandidate,
124
126
  onSyncAssociationTitleField,
125
127
  enableDateVariableAsConstant = false,
128
+ maxAssociationFieldDepth = 2,
126
129
  } = props;
127
130
 
128
- const value = Array.isArray(rawValue) ? rawValue : [];
131
+ const value = React.useMemo(() => (Array.isArray(rawValue) ? rawValue : []), [rawValue]);
129
132
  const [cascaderOptions, setCascaderOptions] = React.useState<FieldAssignCascaderOption[]>(() =>
130
133
  Array.isArray(fieldOptions) ? (fieldOptions as FieldAssignCascaderOption[]) : [],
131
134
  );
@@ -171,6 +174,19 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
171
174
  }
172
175
  }, [getRuleKey, syncingRuleKey, value]);
173
176
 
177
+ const parseTargetPathToSegments = React.useCallback((targetPath?: string): string[] => {
178
+ const raw = String(targetPath || '');
179
+ if (!raw) return [];
180
+ return raw
181
+ .split('.')
182
+ .map((s) => s.trim())
183
+ .filter(Boolean);
184
+ }, []);
185
+
186
+ const isAssociationFieldLike = React.useCallback((field?: CollectionFieldLike | null) => {
187
+ return !!(field?.isAssociationField?.() || field?.target || field?.targetCollection);
188
+ }, []);
189
+
174
190
  const buildCollectionMetaTreeNodes = React.useCallback(
175
191
  (collection: any, basePaths: string[], visited: Set<any>): MetaTreeNode[] => {
176
192
  if (!collection?.getFields) return [];
@@ -199,7 +215,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
199
215
  paths: [...basePaths, name],
200
216
  };
201
217
 
202
- const isAssoc = !!f.isAssociationField?.();
218
+ const isAssoc = isAssociationFieldLike(f);
203
219
  const isToMany = isToManyAssociationField(f);
204
220
  if (isAssoc && !isToMany && f.targetCollection) {
205
221
  node.children = async () =>
@@ -211,7 +227,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
211
227
 
212
228
  return nodes;
213
229
  },
214
- [t],
230
+ [isAssociationFieldLike, t],
215
231
  );
216
232
 
217
233
  const buildItemMetaTree = React.useCallback(
@@ -226,7 +242,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
226
242
  for (let i = 0; i < segs.length; i++) {
227
243
  const seg = segs[i] as string;
228
244
  const field = current?.getField?.(seg);
229
- if (!field?.isAssociationField?.()) break;
245
+ if (!isAssociationFieldLike(field)) break;
230
246
  const toMany = isToManyAssociationField(field);
231
247
  const nextCollection = field?.targetCollection;
232
248
  if (!nextCollection) break;
@@ -291,7 +307,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
291
307
 
292
308
  return [buildObjectNode(levels.length - 1, ['item'], 'item', 'Current item')];
293
309
  },
294
- [buildCollectionMetaTreeNodes, rootCollection, t],
310
+ [buildCollectionMetaTreeNodes, isAssociationFieldLike, parseTargetPathToSegments, rootCollection, t],
295
311
  );
296
312
 
297
313
  const patchItem = (index: number, patch: Partial<FieldAssignRuleItem>) => {
@@ -381,15 +397,6 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
381
397
  [t],
382
398
  );
383
399
 
384
- const parseTargetPathToSegments = React.useCallback((targetPath?: string): string[] => {
385
- const raw = String(targetPath || '');
386
- if (!raw) return [];
387
- return raw
388
- .split('.')
389
- .map((s) => s.trim())
390
- .filter(Boolean);
391
- }, []);
392
-
393
400
  const getFieldLabel = (targetPath?: string) => {
394
401
  const segs = parseTargetPathToSegments(targetPath);
395
402
  if (!segs.length) return undefined;
@@ -413,7 +420,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
413
420
  : seg,
414
421
  ),
415
422
  );
416
- if (field?.isAssociationField?.() && field?.targetCollection) {
423
+ if (isAssociationFieldLike(field as CollectionFieldLike) && field?.targetCollection) {
417
424
  collection = field.targetCollection;
418
425
  }
419
426
  }
@@ -435,7 +442,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
435
442
 
436
443
  const isLast = i === segs.length - 1;
437
444
  if (isLast) {
438
- if (!field?.isAssociationField?.() || !field?.targetCollection) return null;
445
+ if (!isAssociationFieldLike(field) || !field?.targetCollection) return null;
439
446
 
440
447
  const targetCollection = field.targetCollection;
441
448
  const targetFields =
@@ -502,7 +509,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
502
509
  };
503
510
  }
504
511
 
505
- if (!field?.isAssociationField?.() || !field?.targetCollection) {
512
+ if (!isAssociationFieldLike(field) || !field?.targetCollection) {
506
513
  return null;
507
514
  }
508
515
  collection = field.targetCollection;
@@ -510,7 +517,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
510
517
 
511
518
  return null;
512
519
  },
513
- [isTitleFieldCandidate, parseTargetPathToSegments, rootCollection, t],
520
+ [isAssociationFieldLike, isTitleFieldCandidate, parseTargetPathToSegments, rootCollection, t],
514
521
  );
515
522
 
516
523
  const resolveTargetCollectionBySegments = React.useCallback(
@@ -519,42 +526,24 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
519
526
  let collection = rootCollection;
520
527
  for (const seg of segments) {
521
528
  const field = collection?.getField?.(seg);
522
- if (!field?.isAssociationField?.() || !field?.targetCollection) {
529
+ if (!isAssociationFieldLike(field) || !field?.targetCollection) {
523
530
  return null;
524
531
  }
525
532
  collection = field.targetCollection;
526
533
  }
527
534
  return collection || null;
528
535
  },
529
- [rootCollection],
536
+ [isAssociationFieldLike, rootCollection],
530
537
  );
531
538
 
532
539
  const buildChildrenFromCollection = React.useCallback(
533
- (collection: any): FieldAssignCascaderOption[] => {
534
- const fields = typeof collection?.getFields === 'function' ? collection.getFields() || [] : [];
535
- const out: FieldAssignCascaderOption[] = [];
536
- for (const rawField of fields) {
537
- if (!rawField) continue;
538
- const f = rawField as CollectionFieldLike;
539
- const fieldInterface = typeof f.interface === 'string' ? f.interface : undefined;
540
- if (!fieldInterface) continue;
541
- if (fieldInterface === 'formula') continue;
542
-
543
- const name = String(f.name || '');
544
- if (!name) continue;
545
-
546
- const title = t(typeof f.title === 'string' ? f.title : name);
547
- const isAssoc = !!f.isAssociationField?.();
548
- const hasTarget = !!f.targetCollection;
549
- out.push({
550
- label: title,
551
- value: name,
552
- isLeaf: !(isAssoc && hasTarget),
553
- });
554
- }
555
- return out;
540
+ (collection: any, associationDepth: number): FieldAssignCascaderOption[] => {
541
+ return buildFieldAssignCascaderOptionsFromCollection(collection, t, {
542
+ associationDepth,
543
+ maxAssociationFieldDepth,
544
+ });
556
545
  },
557
- [t],
546
+ [maxAssociationFieldDepth, t],
558
547
  );
559
548
 
560
549
  const loadCascaderData = React.useCallback<NonNullable<CascaderProps<FieldAssignCascaderOption>['loadData']>>(
@@ -573,7 +562,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
573
562
  return;
574
563
  }
575
564
 
576
- const children = buildChildrenFromCollection(targetCollection);
565
+ const children = buildChildrenFromCollection(targetCollection, segments.length);
577
566
  if (!children.length) {
578
567
  target.isLeaf = true;
579
568
  } else {
@@ -889,6 +878,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
889
878
  value={item.value}
890
879
  onChange={(v) => patchItem(index, { value: v })}
891
880
  extraMetaTree={extraMetaTree}
881
+ maxAssociationFieldDepth={maxAssociationFieldDepth}
892
882
  {...(getValueInputProps?.(item, index) || {})}
893
883
  enableDateVariableAsConstant={enableDateVariableAsConstant}
894
884
  associationFieldNamesOverride={
@@ -959,6 +949,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
959
949
  value={item.condition || { logic: '$and', items: [] }}
960
950
  onChange={(condition) => patchItem(index, { condition })}
961
951
  extraMetaTree={extraMetaTree}
952
+ maxAssociationFieldDepth={2}
962
953
  />
963
954
  </div>
964
955
  )}
@@ -36,10 +36,11 @@ import {
36
36
  isToManyAssociationField,
37
37
  } from '../internal/utils/modelUtils';
38
38
  import { RunJSValueEditor } from './RunJSValueEditor';
39
- import { resolveOperatorComponent } from '../internal/utils/operatorSchemaHelper';
39
+ import { pickOperatorStyle as pickStyle, resolveOperatorComponent } from '../internal/utils/operatorSchemaHelper';
40
40
  import { InputFieldModel } from '../models/fields/InputFieldModel';
41
41
  import { normalizeFilterValueByOperator } from '../models/blocks/filter-form/valueNormalization';
42
42
  import { FieldAssignExactDatePicker, type ExactDatePickerMode } from './FieldAssignExactDatePicker';
43
+ import { limitAssociationMetaTree } from './filter/metaTreeAssociationDepth';
43
44
 
44
45
  const DATE_FIELD_INTERFACES = new Set(['date', 'datetime', 'datetimeNoTz', 'createdAt', 'updatedAt', 'unixTimestamp']);
45
46
 
@@ -340,6 +341,7 @@ interface Props {
340
341
  * 默认 false,保持历史行为。
341
342
  */
342
343
  enableDateVariableAsConstant?: boolean;
344
+ maxAssociationFieldDepth?: number;
343
345
  }
344
346
 
345
347
  type ResolvedFieldContext = {
@@ -352,14 +354,6 @@ type ResolvedFieldContext = {
352
354
  collectionField: CollectionField | null;
353
355
  };
354
356
 
355
- function isPlainObject(value: unknown): value is Record<string, unknown> {
356
- return !!value && typeof value === 'object' && !Array.isArray(value);
357
- }
358
-
359
- function pickStyle(value: unknown): React.CSSProperties | undefined {
360
- return isPlainObject(value) ? (value as React.CSSProperties) : undefined;
361
- }
362
-
363
357
  function withFullWidthStyle(style?: React.CSSProperties): React.CSSProperties {
364
358
  return { ...style, width: '100%', minWidth: 0 };
365
359
  }
@@ -718,6 +712,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
718
712
  preferFormItemFieldModel,
719
713
  associationFieldNamesOverride,
720
714
  enableDateVariableAsConstant = false,
715
+ maxAssociationFieldDepth = 2,
721
716
  }) => {
722
717
  const flowCtx = useFlowContext<FlowModelContext>();
723
718
  const normalizeEventValue = React.useCallback((eventOrValue: unknown) => {
@@ -859,7 +854,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
859
854
  fieldName,
860
855
  collectionField: nested.collectionField || null,
861
856
  };
862
- }, [flowCtx.model, itemModel, resolveNestedAssociationField, targetPath]);
857
+ }, [flowCtx, itemModel, resolveNestedAssociationField, targetPath]);
863
858
 
864
859
  const { collection, dataSource, blockModel, fieldPath, fieldName, collectionField: cf } = resolved;
865
860
  const itemCollectionField = (resolved?.itemModel as any)?.context?.collectionField;
@@ -1172,7 +1167,11 @@ export const FieldAssignValueInput: React.FC<Props> = ({
1172
1167
  <Comp
1173
1168
  {...fieldModel.props}
1174
1169
  {...xProps}
1175
- style={{ width: '100%', ...(fieldModel.props as any)?.style, ...xProps?.style }}
1170
+ style={{
1171
+ width: '100%',
1172
+ ...pickStyle((fieldModel.props as Record<string, unknown>)?.style),
1173
+ ...pickStyle(xProps?.style),
1174
+ }}
1176
1175
  />
1177
1176
  );
1178
1177
  rewrapReactiveRender(fieldModel);
@@ -1232,7 +1231,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
1232
1231
  );
1233
1232
  };
1234
1233
  return C;
1235
- }, [placeholder, tempRoot, coerceEmptyValueForRenderer, normalizeEventValue]);
1234
+ }, [placeholder, tempRoot, coerceEmptyValueForRenderer, normalizeEventValue, operator]);
1236
1235
 
1237
1236
  const DateVariableConstantEditor = React.useMemo(() => {
1238
1237
  const C: React.FC<any> = (inputProps) => {
@@ -1408,6 +1407,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
1408
1407
  const extra = extraMetaTreeRef.current;
1409
1408
  const extraTree = Array.isArray(extra) ? extra : [];
1410
1409
  const mergedBase = mergeItemMetaTreeForAssignValue(base as MetaTreeNode[], extraTree as MetaTreeNode[]);
1410
+ const limitedBase = limitAssociationMetaTree(mergedBase, { maxAssociationDepth: maxAssociationFieldDepth });
1411
1411
  return [
1412
1412
  {
1413
1413
  title: tExpr('Constant'),
@@ -1418,10 +1418,10 @@ export const FieldAssignValueInput: React.FC<Props> = ({
1418
1418
  },
1419
1419
  { title: tExpr('Null'), name: 'null', type: 'object', paths: ['null'], render: NullComponent },
1420
1420
  { title: tExpr('RunJS'), name: 'runjs', type: 'object', paths: ['runjs'], render: RunJSComponent },
1421
- ...mergedBase,
1421
+ ...limitedBase,
1422
1422
  ];
1423
1423
  };
1424
- }, [flowCtx, ConstantEditor, NullComponent, RunJSComponent]);
1424
+ }, [flowCtx, ConstantEditor, NullComponent, RunJSComponent, maxAssociationFieldDepth]);
1425
1425
 
1426
1426
  const displayValue = React.useMemo(() => {
1427
1427
  if (!useDateVariableConstant) {
@@ -108,6 +108,54 @@ describe('FieldAssignRulesEditor', () => {
108
108
  };
109
109
  };
110
110
 
111
+ const createAssociationFixtureWithoutIsAssociationField = () => {
112
+ const profileCollection = {
113
+ titleField: 'name',
114
+ filterTargetKey: 'id',
115
+ getField: (name: string) => {
116
+ if (name === 'name') {
117
+ return { name: 'name', title: 'Name', interface: 'input' };
118
+ }
119
+ if (name === 'nickname') {
120
+ return { name: 'nickname', title: 'Nickname', interface: 'input' };
121
+ }
122
+ return null;
123
+ },
124
+ getFields: () => [
125
+ {
126
+ name: 'name',
127
+ title: 'Name',
128
+ interface: 'input',
129
+ },
130
+ {
131
+ name: 'nickname',
132
+ title: 'Nickname',
133
+ interface: 'input',
134
+ },
135
+ ],
136
+ };
137
+
138
+ const profileField: any = {
139
+ name: 'profile',
140
+ title: 'Profile',
141
+ type: 'belongsTo',
142
+ interface: 'm2o',
143
+ target: 'profiles',
144
+ targetKey: 'id',
145
+ targetCollection: profileCollection,
146
+ };
147
+
148
+ const rootCollection = {
149
+ getField: (name: string) => (name === 'profile' ? profileField : null),
150
+ getFields: () => [profileField],
151
+ };
152
+
153
+ return {
154
+ profileCollection,
155
+ rootCollection,
156
+ };
157
+ };
158
+
111
159
  const openAdvancedPanel = async () => {
112
160
  await userEvent.click(screen.getByRole('button', { name: /Advanced/i }));
113
161
  await screen.findByText('Title field');
@@ -175,6 +223,68 @@ describe('FieldAssignRulesEditor', () => {
175
223
  });
176
224
  });
177
225
 
226
+ it('recognizes lightweight association fields without isAssociationField()', async () => {
227
+ const { rootCollection } = createAssociationFixtureWithoutIsAssociationField();
228
+ const value: FieldAssignRuleItem[] = [
229
+ {
230
+ key: 'rule-profile',
231
+ enable: true,
232
+ targetPath: 'profile',
233
+ mode: 'assign',
234
+ },
235
+ ];
236
+
237
+ const { container } = render(
238
+ wrap(
239
+ <FieldAssignRulesEditor
240
+ t={t}
241
+ fieldOptions={[]}
242
+ rootCollection={rootCollection}
243
+ value={value}
244
+ showCondition={false}
245
+ isTitleFieldCandidate={() => true}
246
+ />,
247
+ ),
248
+ );
249
+
250
+ await waitFor(() => {
251
+ const input = container.querySelector('[data-testid="mock-value-input"]');
252
+ expect(input?.getAttribute('data-assoc-label')).toBe('name');
253
+ expect(input?.getAttribute('data-assoc-value')).toBe('id');
254
+ });
255
+ });
256
+
257
+ it('resolves nested labels and item meta tree through lightweight association fields', async () => {
258
+ const { rootCollection } = createAssociationFixtureWithoutIsAssociationField();
259
+ const value: FieldAssignRuleItem[] = [
260
+ {
261
+ key: 'rule-profile-nickname',
262
+ enable: true,
263
+ targetPath: 'profile.nickname',
264
+ mode: 'assign',
265
+ },
266
+ ];
267
+
268
+ const { container } = render(
269
+ wrap(
270
+ <FieldAssignRulesEditor
271
+ t={t}
272
+ fieldOptions={[]}
273
+ rootCollection={rootCollection}
274
+ value={value}
275
+ showCondition={false}
276
+ isTitleFieldCandidate={() => true}
277
+ />,
278
+ ),
279
+ );
280
+
281
+ await waitFor(() => {
282
+ expect(container.textContent).toContain('Profile / Nickname');
283
+ const input = container.querySelector('[data-testid="mock-value-input"]');
284
+ expect(input?.getAttribute('data-extra')).toBe('yes');
285
+ });
286
+ });
287
+
178
288
  it('saves selected title field into current assign rule without syncing', async () => {
179
289
  const { rootCollection, value } = createAssociationFixture();
180
290
  const onChange = vi.fn();
@@ -785,7 +895,8 @@ describe('FieldAssignRulesEditor', () => {
785
895
  expect(merged.map((node) => node.name)).toEqual(['formValues', 'item', 'user']);
786
896
  expect(merged.filter((node) => node.name === 'item')).toHaveLength(1);
787
897
 
788
- const itemNode = merged.find((node) => node.name === 'item')!;
898
+ const itemNode = merged.find((node) => node.name === 'item');
899
+ expect(itemNode).toBeTruthy();
789
900
  const parent = (itemNode.children as MetaTreeNode[]).find((node) => node.name === 'parentItem');
790
901
  expect(parent).toBeTruthy();
791
902
 
@@ -9,7 +9,10 @@
9
9
 
10
10
  import { describe, expect, it } from 'vitest';
11
11
  import { buildCustomFieldTargetPath } from '../../internal/utils/modelUtils';
12
- import { collectFieldAssignCascaderOptions } from '../fieldAssignOptions';
12
+ import {
13
+ buildFieldAssignCascaderOptionsFromCollection,
14
+ collectFieldAssignCascaderOptions,
15
+ } from '../fieldAssignOptions';
13
16
 
14
17
  describe('fieldAssignOptions', () => {
15
18
  it('marks subform node as non-leaf so Cascader can lazy-load target collection fields', () => {
@@ -94,4 +97,71 @@ describe('fieldAssignOptions', () => {
94
97
  options.some((o: any) => o?.value === customTargetPath && o?.label === 'Custom age' && o?.isLeaf === true),
95
98
  ).toBe(true);
96
99
  });
100
+
101
+ it('hides configured association fields deeper than relation / relation / field', () => {
102
+ const makeItem = (fieldPath: string, field: any, label: string) => ({
103
+ props: { label },
104
+ getStepParams: (flowKey: string, stepKey: string) => {
105
+ if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath };
106
+ return {};
107
+ },
108
+ subModels: {
109
+ field: {
110
+ context: { collectionField: field },
111
+ },
112
+ },
113
+ });
114
+
115
+ const companyField = {
116
+ name: 'company',
117
+ title: 'Company',
118
+ interface: 'm2o',
119
+ target: 'companies',
120
+ isAssociationField: () => true,
121
+ targetCollection: { getFields: () => [] },
122
+ };
123
+ const nameField = {
124
+ name: 'name',
125
+ title: 'Name',
126
+ interface: 'input',
127
+ isAssociationField: () => false,
128
+ };
129
+ const formBlockModel = {
130
+ context: { collection: null },
131
+ subModels: {
132
+ grid: {
133
+ subModels: {
134
+ items: [
135
+ makeItem('users.department.name', nameField, 'Department name'),
136
+ makeItem('users.department.company', companyField, 'Company'),
137
+ ],
138
+ },
139
+ },
140
+ },
141
+ };
142
+
143
+ const options = collectFieldAssignCascaderOptions({ formBlockModel, t: (s) => s });
144
+
145
+ expect(options.map((item) => item.value)).toContain('name');
146
+ expect(options.map((item) => item.value)).not.toContain('company');
147
+ });
148
+
149
+ it('hides lazy-loaded association selector options after two association levels', () => {
150
+ const collection = {
151
+ getFields: () => [
152
+ { name: 'name', title: 'Name', interface: 'input' },
153
+ {
154
+ name: 'company',
155
+ title: 'Company',
156
+ interface: 'm2o',
157
+ target: 'companies',
158
+ targetCollection: { getFields: () => [] },
159
+ },
160
+ ],
161
+ };
162
+
163
+ const options = buildFieldAssignCascaderOptionsFromCollection(collection, (s) => s, { associationDepth: 2 });
164
+
165
+ expect(options.map((item) => item.value)).toEqual(['name']);
166
+ });
97
167
  });
@@ -76,10 +76,15 @@ function getCollectionFromFormBlockModel(model: any): CollectionLike | null {
76
76
  return collection && typeof collection === 'object' ? (collection as CollectionLike) : null;
77
77
  }
78
78
 
79
- function buildRootOptionsFromCollection(
79
+ export function buildFieldAssignCascaderOptionsFromCollection(
80
80
  collection: CollectionLike | null,
81
81
  t: (key: string) => string,
82
+ options: {
83
+ associationDepth?: number;
84
+ maxAssociationFieldDepth?: number;
85
+ } = {},
82
86
  ): FieldAssignCascaderOption[] {
87
+ const { associationDepth = 0, maxAssociationFieldDepth = 2 } = options;
83
88
  const fields = typeof collection?.getFields === 'function' ? collection.getFields() || [] : [];
84
89
  const out: FieldAssignCascaderOption[] = [];
85
90
  for (const rawField of fields) {
@@ -88,6 +93,7 @@ function buildRootOptionsFromCollection(
88
93
  name?: unknown;
89
94
  title?: unknown;
90
95
  interface?: unknown;
96
+ target?: unknown;
91
97
  isAssociationField?: () => boolean;
92
98
  targetCollection?: any;
93
99
  };
@@ -99,8 +105,11 @@ function buildRootOptionsFromCollection(
99
105
  if (!name) continue;
100
106
  const title = t(typeof f.title === 'string' ? f.title : name);
101
107
 
102
- const isAssoc = !!f.isAssociationField?.();
108
+ const isAssoc = !!(f.isAssociationField?.() || f.target || f.targetCollection);
103
109
  const hasTarget = !!f.targetCollection;
110
+ if (isAssoc && associationDepth >= maxAssociationFieldDepth) {
111
+ continue;
112
+ }
104
113
 
105
114
  out.push({
106
115
  label: title,
@@ -138,8 +147,9 @@ export function collectFieldAssignCascaderOptions(options: {
138
147
  t: (key: string) => string;
139
148
  /** 子表单模型递归深度(FormItemModel 层级);默认不限制(只受实际配置与循环引用约束) */
140
149
  maxFormItemDepth?: number;
150
+ maxAssociationFieldDepth?: number;
141
151
  }): FieldAssignCascaderOption[] {
142
- const { formBlockModel, t, maxFormItemDepth = Number.POSITIVE_INFINITY } = options;
152
+ const { formBlockModel, t, maxFormItemDepth = Number.POSITIVE_INFINITY, maxAssociationFieldDepth = 2 } = options;
143
153
 
144
154
  const rootItems = formBlockModel?.subModels?.grid?.subModels?.items || [];
145
155
  const rootCollection = getCollectionFromFormBlockModel(formBlockModel);
@@ -167,9 +177,14 @@ export function collectFieldAssignCascaderOptions(options: {
167
177
 
168
178
  const fieldModel = item?.subModels?.field;
169
179
  const childItems = fieldModel?.subModels?.grid?.subModels?.items;
170
- const cf: CollectionField | undefined = fieldModel?.context?.collectionField;
171
- const isAssociation = !!cf?.isAssociationField?.();
180
+ const cf: (CollectionField & { target?: unknown; targetCollection?: unknown }) | undefined =
181
+ fieldModel?.context?.collectionField;
182
+ const isAssociation = !!(cf?.isAssociationField?.() || cf?.target || cf?.targetCollection);
172
183
  const hasTargetCollection = !!cf?.targetCollection;
184
+ const associationDepth = isAssociation ? targetPath.split('.').filter(Boolean).length : 0;
185
+ if (isAssociation && associationDepth > maxAssociationFieldDepth) {
186
+ continue;
187
+ }
173
188
 
174
189
  // 1) 子表单/子表单列表:递归展开已配置字段(支持无限深度)。
175
190
  //
@@ -207,6 +222,8 @@ export function collectFieldAssignCascaderOptions(options: {
207
222
  };
208
223
 
209
224
  const configuredOptions = walkItems(rootItems, 1);
210
- const allFieldOptions = buildRootOptionsFromCollection(rootCollection, t);
225
+ const allFieldOptions = buildFieldAssignCascaderOptionsFromCollection(rootCollection, t, {
226
+ maxAssociationFieldDepth,
227
+ });
211
228
  return mergeRootOptions(configuredOptions, allFieldOptions);
212
229
  }