@nocobase/client-v2 2.1.0-beta.44 → 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 (80) 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/AppListRender.d.ts +11 -0
  7. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  8. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +3 -2
  9. package/es/flow/components/ConditionBuilder.d.ts +1 -0
  10. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  11. package/es/flow/components/FieldAssignValueInput.d.ts +1 -0
  12. package/es/flow/components/fieldAssignOptions.d.ts +10 -0
  13. package/es/flow/components/filter/LinkageFilterItem.d.ts +1 -0
  14. package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
  15. package/es/flow/components/filter/metaTreeAssociationDepth.d.ts +13 -0
  16. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +26 -3
  17. package/es/flow/models/base/AssociationFieldGroupModel.d.ts +2 -33
  18. package/es/flow/models/blocks/filter-manager/utils.d.ts +23 -1
  19. package/es/flow/models/blocks/table/TableColumnModel.d.ts +2 -0
  20. package/es/index.mjs +178 -100
  21. package/lib/index.js +187 -109
  22. package/package.json +7 -7
  23. package/src/BaseApplication.tsx +13 -0
  24. package/src/__tests__/browserChecker.test.ts +103 -0
  25. package/src/__tests__/dataSourceRuntime.test.tsx +22 -1
  26. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +5 -0
  27. package/src/authRedirect.ts +43 -0
  28. package/src/collection-field-interface/CollectionFieldInterface.ts +14 -7
  29. package/src/collection-manager/filter-operators/index.ts +29 -2
  30. package/src/components/form/filter/CollectionFilterItem.tsx +10 -2
  31. package/src/components/form/filter/FilterValueInput.tsx +27 -2
  32. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +39 -0
  33. package/src/flow/__tests__/FlowRoute.test.tsx +8 -1
  34. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +369 -2
  35. package/src/flow/actions/dataScope.tsx +8 -1
  36. package/src/flow/actions/linkageRules.tsx +56 -1
  37. package/src/flow/actions/setTargetDataScope.tsx +1 -1
  38. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +31 -3
  39. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +139 -0
  40. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +8 -1
  41. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  42. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +11 -0
  43. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -4
  44. package/src/flow/admin-shell/admin-layout/useApplications.tsx +34 -1
  45. package/src/flow/common/Markdown/Edit.tsx +32 -4
  46. package/src/flow/components/ConditionBuilder.tsx +12 -2
  47. package/src/flow/components/DefaultValue.tsx +14 -8
  48. package/src/flow/components/FieldAssignRulesEditor.tsx +37 -46
  49. package/src/flow/components/FieldAssignValueInput.tsx +14 -14
  50. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +112 -1
  51. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +71 -1
  52. package/src/flow/components/fieldAssignOptions.ts +23 -6
  53. package/src/flow/components/filter/LinkageFilterItem.tsx +26 -12
  54. package/src/flow/components/filter/VariableFilterItem.tsx +32 -13
  55. package/src/flow/components/filter/__tests__/fieldsToOptions.test.ts +48 -0
  56. package/src/flow/components/filter/__tests__/metaTreeAssociationDepth.test.ts +60 -0
  57. package/src/flow/components/filter/fieldsToOptions.ts +9 -4
  58. package/src/flow/components/filter/metaTreeAssociationDepth.ts +69 -0
  59. package/src/flow/internal/utils/operatorSchemaHelper.ts +112 -5
  60. package/src/flow/models/actions/FilterActionModel.tsx +15 -3
  61. package/src/flow/models/base/AssociationFieldGroupModel.tsx +20 -3
  62. package/src/flow/models/base/__tests__/AssociationFieldGroupModel.test.ts +86 -0
  63. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +11 -0
  64. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +14 -12
  65. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +6 -1
  66. package/src/flow/models/blocks/filter-manager/__tests__/getDefaultOperator.test.ts +35 -1
  67. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +148 -0
  68. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/defaultOperator.test.tsx +85 -0
  69. package/src/flow/models/blocks/filter-manager/flow-actions/connectFields.tsx +48 -6
  70. package/src/flow/models/blocks/filter-manager/flow-actions/customizeFilterRender.tsx +14 -44
  71. package/src/flow/models/blocks/filter-manager/flow-actions/defaultOperator.tsx +3 -5
  72. package/src/flow/models/blocks/filter-manager/flow-actions/operatorComponentProps.tsx +2 -12
  73. package/src/flow/models/blocks/filter-manager/utils.ts +143 -4
  74. package/src/flow/models/blocks/form/FormAssociationItemModel.tsx +9 -0
  75. package/src/flow/models/blocks/form/FormItemModel.tsx +9 -0
  76. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +27 -2
  77. package/src/flow/models/blocks/table/TableColumnModel.tsx +15 -5
  78. package/src/flow/models/blocks/table/TableCustomColumnModel.tsx +2 -2
  79. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +18 -0
  80. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +6 -7
@@ -0,0 +1,139 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import type { AppListProps } from '@ant-design/pro-layout/es/components/AppsLogoComponents/types';
11
+ import { css } from '@emotion/css';
12
+ import { Card, Typography, theme } from 'antd';
13
+ import React from 'react';
14
+
15
+ function renderIcon(icon: React.ReactNode | (() => React.ReactNode)) {
16
+ return typeof icon === 'function' ? icon() : icon;
17
+ }
18
+
19
+ export function useAppListRender() {
20
+ const { token } = theme.useToken();
21
+
22
+ return React.useCallback(
23
+ (appList: AppListProps) => {
24
+ const columnCount = Math.min(Math.max(appList.length, 1), 2);
25
+
26
+ return (
27
+ <div
28
+ className={css`
29
+ max-width: calc(100vw - 48px);
30
+ max-height: calc(100vh - 48px);
31
+ overflow: auto;
32
+ padding: ${token.paddingXS}px;
33
+ `}
34
+ >
35
+ <ul
36
+ className={css`
37
+ display: grid;
38
+ grid-template-columns: repeat(${columnCount}, 260px);
39
+ gap: ${token.marginXXS}px;
40
+ margin: 0;
41
+ padding: 0;
42
+ list-style: none;
43
+ `}
44
+ >
45
+ {appList.map((app, index) => (
46
+ <li
47
+ key={`${app.url || ''}-${index}`}
48
+ className={css`
49
+ width: 260px;
50
+ height: 56px;
51
+ list-style: none;
52
+ `}
53
+ >
54
+ <a
55
+ href={app.url}
56
+ target={app.target}
57
+ rel="noreferrer"
58
+ className={css`
59
+ display: block;
60
+ height: 100%;
61
+ color: ${token.colorText};
62
+ text-decoration: none;
63
+
64
+ &:hover {
65
+ color: ${token.colorText};
66
+ }
67
+ `}
68
+ >
69
+ <Card
70
+ bordered={false}
71
+ style={{
72
+ height: '100%',
73
+ borderRadius: token.borderRadius,
74
+ boxShadow: 'none',
75
+ }}
76
+ className={css`
77
+ height: 100%;
78
+ border-radius: ${token.borderRadius}px;
79
+ box-shadow: none;
80
+
81
+ &:hover {
82
+ background: ${token.colorBgTextHover};
83
+ box-shadow: none;
84
+ }
85
+
86
+ .ant-card-body {
87
+ height: 100%;
88
+ padding: ${token.paddingXS}px;
89
+ display: flex;
90
+ align-items: center;
91
+ gap: ${token.marginXS}px;
92
+ }
93
+ `}
94
+ styles={{
95
+ body: {
96
+ background: 'transparent',
97
+ },
98
+ }}
99
+ >
100
+ <span
101
+ className={css`
102
+ display: inline-flex;
103
+ flex: 0 0 auto;
104
+ width: 32px;
105
+ height: 32px;
106
+ align-items: center;
107
+ justify-content: center;
108
+
109
+ img {
110
+ max-width: 32px;
111
+ max-height: 32px;
112
+ }
113
+ `}
114
+ >
115
+ {renderIcon(app.icon)}
116
+ </span>
117
+ <Typography.Text
118
+ ellipsis
119
+ title={typeof app.title === 'string' ? app.title : undefined}
120
+ className={css`
121
+ min-width: 0;
122
+ color: ${token.colorText};
123
+ font-size: ${token.fontSize}px;
124
+ line-height: ${token.lineHeight};
125
+ `}
126
+ >
127
+ {app.title}
128
+ </Typography.Text>
129
+ </Card>
130
+ </a>
131
+ </li>
132
+ ))}
133
+ </ul>
134
+ </div>
135
+ );
136
+ },
137
+ [token],
138
+ );
139
+ }
@@ -9,7 +9,7 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
12
- import { beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
13
  import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
14
14
  import { observer } from '@nocobase/flow-engine';
15
15
  import { MemoryRouter } from 'react-router-dom';
@@ -45,6 +45,9 @@ describe('AdminLayoutModel menu items', () => {
45
45
  let modalConfirmMock: ReturnType<typeof vi.fn>;
46
46
 
47
47
  beforeEach(() => {
48
+ // Fixtures mount the modern client under the `v2` segment; tell the
49
+ // runtime-prefix helper so v2-runtime detection matches.
50
+ (globalThis.window as any).__nocobase_modern_client_prefix__ = 'v2';
48
51
  engine = new FlowEngine();
49
52
  modalConfirmMock = vi.fn().mockResolvedValue(true);
50
53
  engine.registerModels({
@@ -104,6 +107,10 @@ describe('AdminLayoutModel menu items', () => {
104
107
  vi.spyOn(window, 'open').mockImplementation(() => null);
105
108
  });
106
109
 
110
+ afterEach(() => {
111
+ delete (globalThis.window as any).__nocobase_modern_client_prefix__;
112
+ });
113
+
107
114
  const createRoute = (options?: Partial<import('../../../../flow-compat').NocoBaseDesktopRoute>) => ({
108
115
  id: 1,
109
116
  title: 'Page 1',
@@ -18,5 +18,6 @@ export * from './resolveAdminRouteRuntimeTarget';
18
18
  export * from './constants';
19
19
  export * from './ResetThemeTokenAndKeepAlgorithm';
20
20
  export * from './useApplications';
21
+ export * from './AppListRender';
21
22
  export * from './PinnedPluginListLite';
22
23
  export * from './mobile-layout';
@@ -23,6 +23,17 @@ const app = {
23
23
  },
24
24
  } as any;
25
25
 
26
+ // These fixtures mount the modern client under the `v2` segment; tell the
27
+ // runtime-prefix helper so `isV2AdminRuntime` detects it (the server injects
28
+ // this in production).
29
+ beforeAll(() => {
30
+ (window as any).__nocobase_modern_client_prefix__ = 'v2';
31
+ });
32
+
33
+ afterAll(() => {
34
+ delete (window as any).__nocobase_modern_client_prefix__;
35
+ });
36
+
26
37
  describe('resolveAdminRouteRuntimeTarget', () => {
27
38
  it('should resolve flowPage to v2 spa runtime target', () => {
28
39
  expect(
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { getV2EffectiveBasePath } from '../../../authRedirect';
10
+ import { getModernClientPrefix, getV2EffectiveBasePath } from '../../../authRedirect';
11
11
  import type { BaseApplication } from '../../../BaseApplication';
12
12
  import { NocoBaseDesktopRouteType, type NocoBaseDesktopRoute } from '../../../flow-compat';
13
13
 
@@ -26,8 +26,6 @@ export type AdminRouteRuntimeTarget = {
26
26
  reason: AdminRouteRuntimeTargetReason;
27
27
  };
28
28
 
29
- const V2_PUBLIC_PATH_SUFFIX = '/v2/';
30
-
31
29
  type LocationLike = {
32
30
  pathname: string;
33
31
  search?: string;
@@ -69,7 +67,7 @@ function normalizePublicPath(value = '/') {
69
67
  }
70
68
 
71
69
  export function isV2AdminRuntime(app?: ResolveAdminRouteRuntimeTargetOptions['app']) {
72
- return !!app?.getPublicPath && normalizePublicPath(app.getPublicPath()).endsWith(V2_PUBLIC_PATH_SUFFIX);
70
+ return !!app?.getPublicPath && normalizePublicPath(app.getPublicPath()).endsWith(`/${getModernClientPrefix()}/`);
73
71
  }
74
72
 
75
73
  export function toRouterNavigationPath(pathname: string, basename?: string) {
@@ -8,8 +8,41 @@
8
8
  */
9
9
 
10
10
  import { useApp } from '../../../flow-compat';
11
+ import React from 'react';
11
12
 
12
13
  export const useApplications = () => {
13
14
  const app = useApp();
14
- return { Component: app.apps.Component };
15
+ const loadAppList = app.apps.loadAppList;
16
+ const [appList, setAppList] = React.useState([]);
17
+
18
+ React.useEffect(() => {
19
+ let canceled = false;
20
+
21
+ if (!loadAppList) {
22
+ setAppList([]);
23
+ return;
24
+ }
25
+
26
+ void Promise.resolve(loadAppList(app))
27
+ .then((list) => {
28
+ if (!canceled) {
29
+ setAppList(Array.isArray(list) ? list : []);
30
+ }
31
+ })
32
+ .catch((error) => {
33
+ console.error('[NocoBase] Failed to load application switcher list.', error);
34
+ if (!canceled) {
35
+ setAppList([]);
36
+ }
37
+ });
38
+
39
+ return () => {
40
+ canceled = true;
41
+ };
42
+ }, [app, loadAppList]);
43
+
44
+ return {
45
+ Component: app.apps.Component,
46
+ appList,
47
+ };
15
48
  };
@@ -34,13 +34,41 @@ const defaultToolbar = [
34
34
  'code',
35
35
  'inline-code',
36
36
  'upload',
37
- 'fullscreen',
38
37
  ];
39
38
 
40
39
  const NAMESPACE = 'block-markdown';
41
40
 
42
41
  const locales = ['en_US', 'fr_FR', 'pt_BR', 'ja_JP', 'ko_KR', 'ru_RU', 'sv_SE', 'zh_CN', 'zh_TW'];
43
42
 
43
+ type MarkdownToolbarItem =
44
+ | string
45
+ | {
46
+ name: string;
47
+ tipPosition?: string;
48
+ toolbar?: MarkdownToolbarItem[];
49
+ [key: string]: unknown;
50
+ };
51
+
52
+ function placeToolbarTooltipsBelow(toolbar: MarkdownToolbarItem[]) {
53
+ return toolbar.map((item) => {
54
+ if (typeof item === 'string') {
55
+ if (item === '|' || item === 'br') {
56
+ return item;
57
+ }
58
+ return {
59
+ name: item,
60
+ tipPosition: 's',
61
+ };
62
+ }
63
+
64
+ return {
65
+ ...item,
66
+ tipPosition: 's',
67
+ toolbar: item.toolbar ? placeToolbarTooltipsBelow(item.toolbar) : item.toolbar,
68
+ };
69
+ });
70
+ }
71
+
44
72
  const Edit = (props) => {
45
73
  const { disabled, onChange, value, fileCollection, toolbar, vditorRef } = props;
46
74
  const flowCtx = useFlowContext();
@@ -73,7 +101,7 @@ const Edit = (props) => {
73
101
  useEffect(() => {
74
102
  if (!containerRef.current) return;
75
103
 
76
- const toolbarConfig = toolbar ?? defaultToolbar;
104
+ const toolbarConfig = placeToolbarTooltipsBelow(toolbar ?? defaultToolbar);
77
105
  const vditor = new Vditor(containerRef.current, {
78
106
  value: value ?? '',
79
107
  lang,
@@ -321,7 +349,7 @@ export const MarkdownWithContextSelector: React.FC<MarkdownWithContextSelectorPr
321
349
  if (quoteFlag !== false) {
322
350
  setInnerValue(value);
323
351
  }
324
- }, [value]);
352
+ }, [quoteFlag, value]);
325
353
  const handleTextChange = useCallback(
326
354
  (e) => {
327
355
  const val = e ?? '';
@@ -375,7 +403,7 @@ export const MarkdownWithContextSelector: React.FC<MarkdownWithContextSelectorPr
375
403
  editor.focus();
376
404
  });
377
405
  },
378
- [innerValue, onChange],
406
+ [onChange],
379
407
  );
380
408
 
381
409
  const handleVariableSelected = useCallback(
@@ -13,14 +13,24 @@ import { FilterGroup, LinkageFilterItem } from './filter';
13
13
  import { evaluateConditions, FilterGroupType, removeInvalidFilterItems } from '@nocobase/utils/client';
14
14
 
15
15
  export const ConditionBuilder = observer(
16
- (props: { value: FilterGroupType; onChange: (value: FilterGroupType) => void; extraMetaTree?: MetaTreeNode[] }) => {
16
+ (props: {
17
+ value: FilterGroupType;
18
+ onChange: (value: FilterGroupType) => void;
19
+ extraMetaTree?: MetaTreeNode[];
20
+ maxAssociationFieldDepth?: number;
21
+ }) => {
17
22
  const ctx = useFlowContext();
18
23
 
19
24
  return (
20
25
  <FilterGroup
21
26
  value={props.value || { logic: '$and', items: [] }}
22
27
  FilterItem={(itemProps) => (
23
- <LinkageFilterItem model={ctx.model} value={itemProps.value} extraMetaTree={props.extraMetaTree} />
28
+ <LinkageFilterItem
29
+ model={ctx.model}
30
+ value={itemProps.value}
31
+ extraMetaTree={props.extraMetaTree}
32
+ maxAssociationFieldDepth={props.maxAssociationFieldDepth}
33
+ />
24
34
  )}
25
35
  onChange={props.onChange}
26
36
  />
@@ -31,7 +31,7 @@ import { FieldModel } from '../models';
31
31
  import { RecordSelectFieldModel } from '../models/fields/AssociationFieldModel';
32
32
  import { InputFieldModel } from '../models/fields/InputFieldModel';
33
33
  import { ensureOptionsFromUiSchemaEnumIfAbsent } from '../internal/utils/enumOptionsUtils';
34
- import { resolveOperatorComponent } from '../internal/utils/operatorSchemaHelper';
34
+ import { pickOperatorStyle as pickStyle, resolveOperatorComponent } from '../internal/utils/operatorSchemaHelper';
35
35
  import { RunJSValueEditor } from './RunJSValueEditor';
36
36
  import { buildDynamicNamePath } from '../models/blocks/form/dynamicNamePath';
37
37
 
@@ -592,7 +592,7 @@ export const DefaultValue = connect((props: Props) => {
592
592
  }
593
593
  }
594
594
  }
595
- }, [inputProps, value]);
595
+ }, [inputProps]);
596
596
  return (
597
597
  <div style={{ flexGrow: 1 }}>
598
598
  <FlowModelRenderer model={tempRoot} showFlowSettings={false} />
@@ -600,11 +600,13 @@ export const DefaultValue = connect((props: Props) => {
600
600
  );
601
601
  };
602
602
  return ConstantValueEditor;
603
- }, [tempRoot]);
603
+ }, [tempRoot, value]);
604
+
605
+ const modelOperator = (model as { operator?: string })?.operator;
604
606
 
605
607
  // 文本类多关键词:根据已注册的 operator schema 渲染(用于默认值配置)
606
608
  React.useEffect(() => {
607
- const operator = (model as any)?.operator;
609
+ const operator = modelOperator;
608
610
  const fieldModel = tempRoot?.subModels?.fields?.[0];
609
611
  if (!operator || !fieldModel) return;
610
612
  const originalRender = fieldModel['__originalRender'] || fieldModel.render;
@@ -622,17 +624,21 @@ export const DefaultValue = connect((props: Props) => {
622
624
  <Comp
623
625
  {...fieldModel.props}
624
626
  {...xProps}
625
- style={{ width: '100%', ...(fieldModel.props as any)?.style, ...xProps?.style }}
627
+ style={{
628
+ width: '100%',
629
+ ...pickStyle((fieldModel.props as Record<string, unknown>)?.style),
630
+ ...pickStyle(xProps?.style),
631
+ }}
626
632
  />
627
633
  );
628
634
  } else if (typeof originalRender === 'function') {
629
635
  fieldModel.render = originalRender;
630
636
  }
631
- }, [model, tempRoot, (model as any)?.operator]);
637
+ }, [model, tempRoot, modelOperator]);
632
638
 
633
639
  // 根据操作符 schema 的 x-component-props 补全临时字段的输入属性(如多选)
634
640
  React.useEffect(() => {
635
- const operator = (model as any)?.operator;
641
+ const operator = modelOperator;
636
642
  const fieldModel = tempRoot?.subModels?.fields?.[0];
637
643
  if (!fieldModel || !operator) return;
638
644
  const ops = model.collectionField?.filterable?.operators || [];
@@ -641,7 +647,7 @@ export const DefaultValue = connect((props: Props) => {
641
647
  if (xComponentProps) {
642
648
  fieldModel.setProps(xComponentProps);
643
649
  }
644
- }, [model, tempRoot, (model as any)?.operator]);
650
+ }, [model, tempRoot, modelOperator]);
645
651
 
646
652
  const NullComponent = useMemo(() => {
647
653
  function NullValuePlaceholder() {
@@ -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
  )}