@nocobase/flow-engine 2.1.0-alpha.1 → 2.1.0-alpha.3

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 (252) hide show
  1. package/lib/BlockScopedFlowEngine.js +0 -1
  2. package/lib/FlowDefinition.d.ts +2 -0
  3. package/lib/JSRunner.d.ts +6 -0
  4. package/lib/JSRunner.js +32 -2
  5. package/lib/ViewScopedFlowEngine.js +3 -0
  6. package/lib/acl/Acl.js +13 -3
  7. package/lib/components/FlowContextSelector.js +155 -10
  8. package/lib/components/dnd/gridDragPlanner.d.ts +1 -0
  9. package/lib/components/dnd/gridDragPlanner.js +53 -1
  10. package/lib/components/settings/wrappers/component/SwitchWithTitle.js +2 -1
  11. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +76 -15
  12. package/lib/components/settings/wrappers/contextual/FlowsContextMenu.js +24 -4
  13. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +5 -1
  14. package/lib/components/variables/VariableInput.js +9 -4
  15. package/lib/components/variables/VariableTag.js +46 -39
  16. package/lib/components/variables/utils.d.ts +7 -0
  17. package/lib/components/variables/utils.js +42 -2
  18. package/lib/data-source/index.d.ts +7 -27
  19. package/lib/data-source/index.js +84 -51
  20. package/lib/executor/FlowExecutor.d.ts +2 -1
  21. package/lib/executor/FlowExecutor.js +163 -22
  22. package/lib/flowContext.d.ts +230 -7
  23. package/lib/flowContext.js +2267 -148
  24. package/lib/flowEngine.d.ts +21 -0
  25. package/lib/flowEngine.js +55 -13
  26. package/lib/flowI18n.js +6 -4
  27. package/lib/flowSettings.js +17 -11
  28. package/lib/index.d.ts +7 -1
  29. package/lib/index.js +21 -0
  30. package/lib/locale/en-US.json +9 -2
  31. package/lib/locale/index.d.ts +14 -0
  32. package/lib/locale/zh-CN.json +8 -1
  33. package/lib/models/CollectionFieldModel.d.ts +1 -0
  34. package/lib/models/CollectionFieldModel.js +3 -2
  35. package/lib/models/flowModel.d.ts +7 -0
  36. package/lib/models/flowModel.js +66 -1
  37. package/lib/provider.js +7 -6
  38. package/lib/resources/baseRecordResource.d.ts +5 -0
  39. package/lib/resources/baseRecordResource.js +24 -0
  40. package/lib/resources/multiRecordResource.d.ts +1 -0
  41. package/lib/resources/multiRecordResource.js +11 -4
  42. package/lib/resources/singleRecordResource.js +2 -0
  43. package/lib/resources/sqlResource.d.ts +4 -3
  44. package/lib/resources/sqlResource.js +8 -3
  45. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +12 -2
  46. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +2 -2
  47. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.d.ts +16 -0
  48. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +125 -0
  49. package/lib/runjs-context/contexts/JSItemRunJSContext.js +12 -2
  50. package/lib/runjs-context/contexts/base.js +706 -41
  51. package/lib/runjs-context/contributions.d.ts +33 -0
  52. package/lib/runjs-context/contributions.js +88 -0
  53. package/lib/runjs-context/helpers.js +12 -1
  54. package/lib/runjs-context/setup.js +6 -0
  55. package/lib/runjs-context/snippets/global/api-request.snippet.js +3 -3
  56. package/lib/runjs-context/snippets/global/import-esm.snippet.js +2 -3
  57. package/lib/runjs-context/snippets/global/query-selector.snippet.js +8 -3
  58. package/lib/runjs-context/snippets/global/require-amd.snippet.js +1 -1
  59. package/lib/runjs-context/snippets/index.d.ts +11 -1
  60. package/lib/runjs-context/snippets/index.js +61 -40
  61. package/lib/runjs-context/snippets/scene/block/add-event-listener.snippet.js +10 -7
  62. package/lib/runjs-context/snippets/scene/block/api-fetch-render-list.snippet.js +3 -3
  63. package/lib/runjs-context/snippets/scene/block/chartjs-bar.snippet.js +2 -2
  64. package/lib/runjs-context/snippets/scene/block/echarts-init.snippet.js +2 -2
  65. package/lib/runjs-context/snippets/scene/block/render-iframe.snippet.js +2 -2
  66. package/lib/runjs-context/snippets/scene/block/render-react.snippet.js +1 -1
  67. package/lib/runjs-context/snippets/scene/block/render-statistics.snippet.js +1 -1
  68. package/lib/runjs-context/snippets/scene/block/render-timeline.snippet.js +1 -1
  69. package/lib/runjs-context/snippets/scene/block/resource-example.snippet.js +5 -5
  70. package/lib/runjs-context/snippets/scene/block/three-users-orbit.snippet.js +6 -6
  71. package/lib/runjs-context/snippets/scene/block/vue-component.snippet.js +3 -4
  72. package/lib/runjs-context/snippets/scene/detail/color-by-value.snippet.js +1 -1
  73. package/lib/runjs-context/snippets/scene/detail/copy-to-clipboard.snippet.js +20 -3
  74. package/lib/runjs-context/snippets/scene/detail/format-number.snippet.js +1 -1
  75. package/lib/runjs-context/snippets/scene/detail/innerHTML-value.snippet.js +1 -1
  76. package/lib/runjs-context/snippets/scene/detail/percentage-bar.snippet.js +3 -3
  77. package/lib/runjs-context/snippets/scene/detail/relative-time.snippet.js +3 -3
  78. package/lib/runjs-context/snippets/scene/detail/status-tag.snippet.js +2 -2
  79. package/lib/runjs-context/snippets/scene/form/cascade-select.snippet.js +1 -1
  80. package/lib/runjs-context/snippets/scene/form/render-basic.snippet.js +2 -2
  81. package/lib/runjs-context/snippets/scene/table/cell-open-dialog.snippet.js +6 -3
  82. package/lib/runjs-context/snippets/scene/table/concat-fields.snippet.js +3 -1
  83. package/lib/runjsLibs.d.ts +28 -0
  84. package/lib/runjsLibs.js +532 -0
  85. package/lib/scheduler/ModelOperationScheduler.d.ts +2 -0
  86. package/lib/scheduler/ModelOperationScheduler.js +25 -21
  87. package/lib/types.d.ts +27 -0
  88. package/lib/utils/associationObjectVariable.d.ts +2 -2
  89. package/lib/utils/createCollectionContextMeta.js +1 -0
  90. package/lib/utils/createEphemeralContext.js +2 -2
  91. package/lib/utils/dateVariable.d.ts +16 -0
  92. package/lib/utils/dateVariable.js +380 -0
  93. package/lib/utils/exceptions.d.ts +7 -0
  94. package/lib/utils/exceptions.js +10 -0
  95. package/lib/utils/index.d.ts +8 -3
  96. package/lib/utils/index.js +45 -0
  97. package/lib/utils/params-resolvers.js +16 -9
  98. package/lib/utils/resolveModuleUrl.d.ts +58 -0
  99. package/lib/utils/resolveModuleUrl.js +65 -0
  100. package/lib/utils/resolveRunJSObjectValues.d.ts +16 -0
  101. package/lib/utils/resolveRunJSObjectValues.js +61 -0
  102. package/lib/utils/runjsModuleLoader.d.ts +58 -0
  103. package/lib/utils/runjsModuleLoader.js +422 -0
  104. package/lib/utils/runjsTemplateCompat.d.ts +35 -0
  105. package/lib/utils/runjsTemplateCompat.js +743 -0
  106. package/lib/utils/runjsValue.d.ts +29 -0
  107. package/lib/utils/runjsValue.js +275 -0
  108. package/lib/utils/safeGlobals.d.ts +18 -8
  109. package/lib/utils/safeGlobals.js +164 -17
  110. package/lib/utils/schema-utils.d.ts +10 -0
  111. package/lib/utils/schema-utils.js +61 -0
  112. package/lib/views/createViewMeta.d.ts +0 -7
  113. package/lib/views/createViewMeta.js +19 -70
  114. package/lib/views/index.d.ts +1 -2
  115. package/lib/views/index.js +4 -3
  116. package/lib/views/useDialog.js +8 -3
  117. package/lib/views/useDrawer.js +7 -2
  118. package/lib/views/usePage.d.ts +4 -0
  119. package/lib/views/usePage.js +43 -6
  120. package/lib/views/usePopover.js +4 -1
  121. package/lib/views/viewEvents.d.ts +17 -0
  122. package/lib/views/viewEvents.js +90 -0
  123. package/package.json +4 -4
  124. package/src/BlockScopedFlowEngine.ts +2 -5
  125. package/src/JSRunner.ts +44 -2
  126. package/src/ViewScopedFlowEngine.ts +4 -0
  127. package/src/__tests__/JSRunner.test.ts +64 -0
  128. package/src/__tests__/createViewMeta.popup.test.ts +62 -1
  129. package/src/__tests__/flowContext.test.ts +693 -1
  130. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +63 -0
  131. package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
  132. package/src/__tests__/flowRunJSContextDefine.test.ts +63 -0
  133. package/src/__tests__/flowRuntimeContext.test.ts +2 -1
  134. package/src/__tests__/flowSettings.open.test.tsx +123 -19
  135. package/src/__tests__/provider.test.tsx +0 -5
  136. package/src/__tests__/runjsContext.test.ts +10 -7
  137. package/src/__tests__/runjsContextImplementations.test.ts +34 -3
  138. package/src/__tests__/runjsContextRuntime.test.ts +3 -3
  139. package/src/__tests__/runjsContributions.test.ts +89 -0
  140. package/src/__tests__/runjsExternalLibs.test.ts +242 -0
  141. package/src/__tests__/runjsLibsLazyLoading.test.ts +44 -0
  142. package/src/__tests__/runjsLocales.test.ts +4 -1
  143. package/src/__tests__/runjsPreprocessDefault.test.ts +49 -0
  144. package/src/__tests__/runjsRuntimeFeatures.test.ts +166 -0
  145. package/src/__tests__/runjsSnippets.test.ts +40 -3
  146. package/src/acl/Acl.tsx +3 -3
  147. package/src/components/FlowContextSelector.tsx +208 -12
  148. package/src/components/__tests__/gridDragPlanner.test.ts +141 -1
  149. package/src/components/dnd/gridDragPlanner.ts +60 -0
  150. package/src/components/settings/wrappers/component/SwitchWithTitle.tsx +2 -1
  151. package/src/components/settings/wrappers/component/__tests__/InlineControls.test.tsx +74 -0
  152. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +109 -16
  153. package/src/components/settings/wrappers/contextual/FlowsContextMenu.tsx +41 -7
  154. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +13 -2
  155. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +157 -5
  156. package/src/components/variables/VariableInput.tsx +12 -4
  157. package/src/components/variables/VariableTag.tsx +54 -45
  158. package/src/components/variables/__tests__/FlowContextSelector.test.tsx +260 -3
  159. package/src/components/variables/__tests__/VariableTag.test.tsx +50 -0
  160. package/src/components/variables/__tests__/utils.test.ts +81 -3
  161. package/src/components/variables/utils.ts +67 -6
  162. package/src/data-source/index.ts +88 -110
  163. package/src/executor/FlowExecutor.ts +200 -23
  164. package/src/executor/__tests__/flowExecutor.test.ts +66 -0
  165. package/src/flowContext.ts +2986 -211
  166. package/src/flowEngine.ts +58 -13
  167. package/src/flowI18n.ts +7 -5
  168. package/src/flowSettings.ts +18 -12
  169. package/src/index.ts +14 -1
  170. package/src/locale/en-US.json +9 -2
  171. package/src/locale/zh-CN.json +8 -1
  172. package/src/models/CollectionFieldModel.tsx +3 -1
  173. package/src/models/__tests__/dispatchEvent.when.test.ts +554 -0
  174. package/src/models/__tests__/flowModel.clone.test.ts +416 -0
  175. package/src/models/__tests__/flowModel.test.ts +20 -4
  176. package/src/models/flowModel.tsx +94 -1
  177. package/src/provider.tsx +9 -7
  178. package/src/resources/__tests__/multiRecordResource.test.ts +44 -0
  179. package/src/resources/__tests__/sqlResource.test.ts +60 -0
  180. package/src/resources/baseRecordResource.ts +31 -0
  181. package/src/resources/multiRecordResource.ts +11 -4
  182. package/src/resources/singleRecordResource.ts +3 -0
  183. package/src/resources/sqlResource.ts +11 -6
  184. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +10 -0
  185. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +6 -2
  186. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +106 -0
  187. package/src/runjs-context/contexts/JSItemRunJSContext.ts +10 -0
  188. package/src/runjs-context/contexts/base.ts +715 -44
  189. package/src/runjs-context/contributions.ts +88 -0
  190. package/src/runjs-context/helpers.ts +11 -1
  191. package/src/runjs-context/setup.ts +6 -0
  192. package/src/runjs-context/snippets/global/api-request.snippet.ts +3 -3
  193. package/src/runjs-context/snippets/global/import-esm.snippet.ts +2 -3
  194. package/src/runjs-context/snippets/global/query-selector.snippet.ts +8 -3
  195. package/src/runjs-context/snippets/global/require-amd.snippet.ts +1 -1
  196. package/src/runjs-context/snippets/index.ts +75 -41
  197. package/src/runjs-context/snippets/scene/block/add-event-listener.snippet.ts +11 -13
  198. package/src/runjs-context/snippets/scene/block/api-fetch-render-list.snippet.ts +3 -3
  199. package/src/runjs-context/snippets/scene/block/chartjs-bar.snippet.ts +2 -2
  200. package/src/runjs-context/snippets/scene/block/echarts-init.snippet.ts +2 -2
  201. package/src/runjs-context/snippets/scene/block/render-iframe.snippet.ts +2 -2
  202. package/src/runjs-context/snippets/scene/block/render-react.snippet.ts +1 -1
  203. package/src/runjs-context/snippets/scene/block/render-statistics.snippet.ts +1 -1
  204. package/src/runjs-context/snippets/scene/block/render-timeline.snippet.ts +1 -1
  205. package/src/runjs-context/snippets/scene/block/resource-example.snippet.ts +6 -11
  206. package/src/runjs-context/snippets/scene/block/three-users-orbit.snippet.ts +6 -6
  207. package/src/runjs-context/snippets/scene/block/vue-component.snippet.ts +3 -4
  208. package/src/runjs-context/snippets/scene/detail/color-by-value.snippet.ts +1 -1
  209. package/src/runjs-context/snippets/scene/detail/copy-to-clipboard.snippet.ts +20 -3
  210. package/src/runjs-context/snippets/scene/detail/format-number.snippet.ts +1 -1
  211. package/src/runjs-context/snippets/scene/detail/innerHTML-value.snippet.ts +1 -1
  212. package/src/runjs-context/snippets/scene/detail/percentage-bar.snippet.ts +3 -3
  213. package/src/runjs-context/snippets/scene/detail/relative-time.snippet.ts +3 -3
  214. package/src/runjs-context/snippets/scene/detail/status-tag.snippet.ts +2 -2
  215. package/src/runjs-context/snippets/scene/form/cascade-select.snippet.ts +1 -1
  216. package/src/runjs-context/snippets/scene/form/render-basic.snippet.ts +3 -8
  217. package/src/runjs-context/snippets/scene/table/cell-open-dialog.snippet.ts +6 -3
  218. package/src/runjs-context/snippets/scene/table/concat-fields.snippet.ts +3 -1
  219. package/src/runjsLibs.ts +622 -0
  220. package/src/scheduler/ModelOperationScheduler.ts +27 -21
  221. package/src/types.ts +38 -1
  222. package/src/utils/__tests__/dateVariable.test.ts +101 -0
  223. package/src/utils/__tests__/params-resolvers.test.ts +40 -0
  224. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +38 -0
  225. package/src/utils/__tests__/runjsTemplateCompat.test.ts +159 -0
  226. package/src/utils/__tests__/runjsValue.test.ts +44 -0
  227. package/src/utils/__tests__/safeGlobals.test.ts +57 -2
  228. package/src/utils/__tests__/utils.test.ts +95 -0
  229. package/src/utils/associationObjectVariable.ts +2 -2
  230. package/src/utils/createCollectionContextMeta.ts +1 -0
  231. package/src/utils/createEphemeralContext.ts +5 -4
  232. package/src/utils/dateVariable.ts +397 -0
  233. package/src/utils/exceptions.ts +11 -0
  234. package/src/utils/index.ts +37 -3
  235. package/src/utils/params-resolvers.ts +23 -9
  236. package/src/utils/resolveModuleUrl.ts +91 -0
  237. package/src/utils/resolveRunJSObjectValues.ts +46 -0
  238. package/src/utils/runjsModuleLoader.ts +553 -0
  239. package/src/utils/runjsTemplateCompat.ts +828 -0
  240. package/src/utils/runjsValue.ts +287 -0
  241. package/src/utils/safeGlobals.ts +188 -17
  242. package/src/utils/schema-utils.ts +79 -0
  243. package/src/views/__tests__/FlowView.usePage.test.tsx +54 -1
  244. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +35 -8
  245. package/src/views/__tests__/viewEvents.resolveOpenerEngine.test.ts +28 -0
  246. package/src/views/createViewMeta.ts +22 -75
  247. package/src/views/index.tsx +1 -2
  248. package/src/views/useDialog.tsx +9 -2
  249. package/src/views/useDrawer.tsx +8 -1
  250. package/src/views/usePage.tsx +51 -5
  251. package/src/views/usePopover.tsx +4 -1
  252. package/src/views/viewEvents.ts +55 -0
@@ -8,12 +8,13 @@
8
8
  */
9
9
 
10
10
  import React, { useCallback, useRef, useMemo, useState, useEffect } from 'react';
11
- import { Button, Cascader, Tooltip } from 'antd';
11
+ import { Button, Cascader, Input, Tooltip, theme } from 'antd';
12
12
  import { QuestionCircleOutlined } from '@ant-design/icons';
13
13
  import { cx, css } from '@emotion/css';
14
14
  import type { ContextSelectorItem, FlowContextSelectorProps } from './variables/types';
15
15
  import {
16
16
  buildContextSelectorItems,
17
+ filterLoadedContextSelectorItems,
17
18
  formatPathToValue,
18
19
  parseValueToPath,
19
20
  preloadContextSelectorPath,
@@ -34,6 +35,52 @@ const cascaderPopupAutoHeightClassName = css`
34
35
  }
35
36
  `;
36
37
 
38
+ type SelectedPathInfo = {
39
+ text: string;
40
+ meta?: ContextSelectorItem['meta'];
41
+ };
42
+
43
+ const normalizePath = (path: unknown): string[] | undefined => {
44
+ if (!Array.isArray(path)) {
45
+ return undefined;
46
+ }
47
+
48
+ return path.map((segment) => String(segment));
49
+ };
50
+
51
+ const getSelectedPathInfo = (path: string[] | undefined, options: ContextSelectorItem[]): SelectedPathInfo => {
52
+ if (!Array.isArray(path) || path.length === 0) {
53
+ return { text: '', meta: undefined };
54
+ }
55
+
56
+ const labels: string[] = [];
57
+ let currentOptions = options;
58
+ let selectedMeta: ContextSelectorItem['meta'] | undefined;
59
+
60
+ for (const segment of path) {
61
+ const matchedOption = currentOptions.find((item) => String(item.value) === String(segment));
62
+ if (!matchedOption) {
63
+ break;
64
+ }
65
+
66
+ const label =
67
+ typeof matchedOption.meta?.title === 'string'
68
+ ? matchedOption.meta.title
69
+ : typeof matchedOption.label === 'string'
70
+ ? matchedOption.label
71
+ : String(matchedOption.value);
72
+
73
+ labels.push(label);
74
+ selectedMeta = matchedOption.meta;
75
+ currentOptions = Array.isArray(matchedOption.children) ? matchedOption.children : [];
76
+ }
77
+
78
+ return {
79
+ text: labels.join(' / '),
80
+ meta: selectedMeta,
81
+ };
82
+ };
83
+
37
84
  const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
38
85
  value,
39
86
  onChange,
@@ -47,13 +94,15 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
47
94
  ignoreFieldNames,
48
95
  ...cascaderProps
49
96
  }) => {
97
+ const { token } = theme.useToken();
98
+
50
99
  // 记录最后点击的路径,用于双击检测
51
100
  const lastSelectedRef = useRef<{ path: string; time: number } | null>(null);
52
101
 
53
102
  const { resolvedMetaTree, loading } = useResolvedMetaTree(metaTree);
54
103
 
55
104
  // 获取引擎上下文中的翻译函数,若不可用则回退为原文
56
- const flowCtx = useFlowContext<any>();
105
+ const flowCtx = useFlowContext();
57
106
 
58
107
  const translateOptions = useCallback(
59
108
  (items: ContextSelectorItem[] | undefined): ContextSelectorItem[] => {
@@ -63,7 +112,9 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
63
112
  const meta = o.meta;
64
113
  const disabled = meta ? !!(typeof meta.disabled === 'function' ? meta.disabled() : meta.disabled) : false;
65
114
  const disabledReason = meta
66
- ? ((typeof meta.disabledReason === 'function' ? meta.disabledReason() : meta.disabledReason) as any)
115
+ ? typeof meta.disabledReason === 'function'
116
+ ? meta.disabledReason()
117
+ : meta.disabledReason
67
118
  : undefined;
68
119
 
69
120
  // 文本国际化:仅当 label 为字符串时进行翻译
@@ -98,7 +149,11 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
98
149
 
99
150
  // 用于强制重新渲染的状态
100
151
  const [updateFlag, setUpdateFlag] = useState(0);
152
+ const [searchText, setSearchText] = useState('');
153
+ const [dropdownOpen, setDropdownOpen] = useState(false);
154
+ const inlineFocusByPointerRef = useRef(false);
101
155
  const triggerUpdate = useCallback(() => setUpdateFlag((prev) => prev + 1), []);
156
+ const isSearchEnabled = showSearch || children === null;
102
157
 
103
158
  // 构建选项
104
159
  // 注意:rc-cascader 内部对 options 做了基于引用的缓存(useEntities)。
@@ -106,13 +161,22 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
106
161
  // 触发 rc-cascader 重新构建 pathKeyEntities,避免二级节点未被索引导致的报错。
107
162
  const options = useMemo(() => {
108
163
  if (!resolvedMetaTree) return [];
164
+ const refreshSeq = updateFlag;
109
165
  const base = buildContextSelectorItems(resolvedMetaTree);
110
- return translateOptions(base).filter((item) => {
166
+ const filtered = translateOptions(base).filter((item) => {
111
167
  if (!ignoreFieldNames || ignoreFieldNames.length === 0) return true;
112
168
  return !ignoreFieldNames.includes(item.meta?.name || '');
113
169
  });
170
+ return refreshSeq >= 0 ? filtered : [];
114
171
  }, [resolvedMetaTree, updateFlag, translateOptions, ignoreFieldNames]);
115
172
 
173
+ const displayOptions = useMemo(() => {
174
+ if (!isSearchEnabled || !searchText.trim()) {
175
+ return options;
176
+ }
177
+ return filterLoadedContextSelectorItems(options, searchText);
178
+ }, [isSearchEnabled, options, searchText]);
179
+
116
180
  // 内部展开路径:在 onlyLeafSelectable=true 时,点击父节点不会触发 onChange,
117
181
  // 但会触发 loadData。我们在此记录路径以在懒加载后保持展开。
118
182
  const [tempSelectedPath, setTempSelectedPath] = useState<string[]>([]);
@@ -158,23 +222,36 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
158
222
  triggerUpdate();
159
223
  }
160
224
  },
161
- [triggerUpdate],
225
+ [triggerUpdate, translateOptions],
162
226
  );
163
227
 
164
228
  const currentPath = useMemo(() => {
165
- return customParseValueToPath(value);
229
+ return normalizePath(customParseValueToPath(value));
166
230
  }, [value, customParseValueToPath]);
167
231
 
168
232
  // 当 metaTree 为子层(如 getPropertyMetaTree('{{ ctx.collection }}') 返回的是 collection 的子节点)
169
233
  // 而 value path 仍包含根键(如 ['collection', 'field'])时,自动丢弃不存在的首段,确保级联能正确对齐。
170
234
  const effectivePath = useMemo(() => {
171
235
  if (!currentPath || currentPath.length === 0) return currentPath;
236
+
237
+ if (options.length === 0) {
238
+ return currentPath;
239
+ }
240
+
172
241
  const topValues = new Set(options.map((o) => String(o.value)));
173
242
  const needTrim = !topValues.has(String(currentPath[0]));
174
243
  const fixed = needTrim ? currentPath.slice(1) : currentPath;
175
244
  return fixed;
176
245
  }, [currentPath, options]);
177
246
 
247
+ const cascaderValue = useMemo(() => {
248
+ if (tempSelectedPath.length > 0) {
249
+ return tempSelectedPath;
250
+ }
251
+
252
+ return Array.isArray(effectivePath) ? effectivePath : undefined;
253
+ }, [effectivePath, tempSelectedPath]);
254
+
178
255
  // 预加载:当存在有效路径时,按路径逐级加载 children,保证默认展开和选中路径可用
179
256
  const pathToPreload = useMemo(() => {
180
257
  const finalPath = effectivePath && effectivePath.length > 0 ? effectivePath : tempSelectedPath;
@@ -251,21 +328,140 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
251
328
  return cx(cascaderPopupAutoHeightClassName, cascaderProps.popupClassName);
252
329
  }, [cascaderProps.popupClassName]);
253
330
 
331
+ const cascaderSearchInputClassName = useMemo(() => {
332
+ return css`
333
+ padding: 8px;
334
+ border-bottom: 1px solid ${token.colorSplit};
335
+ `;
336
+ }, [token.colorSplit]);
337
+
338
+ const {
339
+ onDropdownVisibleChange: cascaderOnDropdownVisibleChange,
340
+ dropdownRender: cascaderDropdownRender,
341
+ ...restCascaderProps
342
+ } = cascaderProps;
343
+
344
+ const selectedPathInfo = useMemo(() => getSelectedPathInfo(effectivePath, options), [effectivePath, options]);
345
+
346
+ const mergedOpen = open !== undefined ? open : children === null ? dropdownOpen : undefined;
347
+
348
+ const isDropdownVisible = !!mergedOpen;
349
+
350
+ const handleDropdownVisibleChange = useCallback(
351
+ (visible: boolean) => {
352
+ if (open === undefined) {
353
+ setDropdownOpen(visible);
354
+ }
355
+ if (!visible) {
356
+ setSearchText('');
357
+ }
358
+ cascaderOnDropdownVisibleChange?.(visible);
359
+ },
360
+ [cascaderOnDropdownVisibleChange, open],
361
+ );
362
+
363
+ const renderDropdown = useCallback(
364
+ (menu: React.ReactElement) => {
365
+ const cascaderMenuNode = cascaderDropdownRender ? cascaderDropdownRender(menu) : menu;
366
+ const cascaderMenu = React.isValidElement(cascaderMenuNode) ? cascaderMenuNode : <>{cascaderMenuNode}</>;
367
+ if (!isSearchEnabled || children === null) {
368
+ return cascaderMenu;
369
+ }
370
+
371
+ return (
372
+ <>
373
+ <div className={cascaderSearchInputClassName}>
374
+ <Input
375
+ allowClear
376
+ size="small"
377
+ value={searchText}
378
+ placeholder={flowCtx.t('Search')}
379
+ onChange={(e) => setSearchText(e.target.value)}
380
+ onKeyDown={(e) => e.stopPropagation()}
381
+ />
382
+ </div>
383
+ {cascaderMenu}
384
+ </>
385
+ );
386
+ },
387
+ [cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText],
388
+ );
389
+
390
+ const inlinePlaceholder =
391
+ typeof restCascaderProps.placeholder === 'string' ? restCascaderProps.placeholder : flowCtx.t('Search');
392
+ const hasSelectedPath = Array.isArray(effectivePath) && effectivePath.length > 0;
393
+
394
+ const handleInlineInputFocus = useCallback(() => {
395
+ if (open === undefined && !inlineFocusByPointerRef.current) {
396
+ setDropdownOpen(true);
397
+ }
398
+ }, [open]);
399
+
400
+ const markInlineFocusByPointer = useCallback(() => {
401
+ inlineFocusByPointerRef.current = true;
402
+ }, []);
403
+
404
+ const resetInlineFocusByPointer = useCallback(() => {
405
+ inlineFocusByPointerRef.current = false;
406
+ }, []);
407
+
408
+ const handleInlineInputChange = useCallback(
409
+ (event: React.ChangeEvent<HTMLInputElement>) => {
410
+ const nextValue = event.target.value;
411
+
412
+ // 下拉关闭态下点击清空:应清空真实已选值,而不是仅清空搜索词。
413
+ if (!isDropdownVisible && nextValue === '' && hasSelectedPath) {
414
+ setTempSelectedPath([]);
415
+ // 清空语义:传空 meta,确保上层(如 VariableInput)进入 clear 分支。
416
+ onChange?.('', undefined);
417
+ return;
418
+ }
419
+
420
+ if (open === undefined && !isDropdownVisible) {
421
+ setDropdownOpen(true);
422
+ }
423
+
424
+ setSearchText(nextValue);
425
+ },
426
+ [hasSelectedPath, isDropdownVisible, onChange, open],
427
+ );
428
+
429
+ const inlinePathText = Array.isArray(effectivePath) ? effectivePath.join(' / ') : '';
430
+ const inlineInputValue = isDropdownVisible ? searchText : selectedPathInfo.text || inlinePathText;
431
+
254
432
  return (
255
433
  <Cascader
256
- {...cascaderProps}
257
- options={options}
258
- value={tempSelectedPath && tempSelectedPath.length > 0 ? tempSelectedPath : effectivePath}
434
+ {...restCascaderProps}
435
+ options={displayOptions}
436
+ value={cascaderValue}
259
437
  onChange={handleChange}
260
438
  loadData={handleLoadData}
261
439
  loading={loading}
262
440
  changeOnSelect={!onlyLeafSelectable}
263
441
  expandTrigger="click"
264
- open={open}
265
- showSearch={children === null}
442
+ open={mergedOpen}
443
+ showSearch={false}
266
444
  popupClassName={mergedPopupClassName}
445
+ dropdownRender={renderDropdown}
446
+ onDropdownVisibleChange={handleDropdownVisibleChange}
267
447
  >
268
- {children === null ? null : children || defaultChildren}
448
+ {children === null ? (
449
+ <Input
450
+ allowClear
451
+ value={inlineInputValue}
452
+ placeholder={inlinePlaceholder}
453
+ onMouseDown={markInlineFocusByPointer}
454
+ onMouseUp={resetInlineFocusByPointer}
455
+ onMouseLeave={resetInlineFocusByPointer}
456
+ onFocus={handleInlineInputFocus}
457
+ onBlur={resetInlineFocusByPointer}
458
+ onChange={handleInlineInputChange}
459
+ onKeyDown={(e) => e.stopPropagation()}
460
+ disabled={restCascaderProps.disabled}
461
+ />
462
+ ) : (
463
+ children || defaultChildren
464
+ )}
269
465
  </Cascader>
270
466
  );
271
467
  };
@@ -19,9 +19,14 @@ import {
19
19
 
20
20
  const rect = { top: 0, left: 0, width: 100, height: 100 };
21
21
 
22
- const createLayout = (rows: Record<string, string[][]>, sizes: Record<string, number[]>): GridLayoutData => ({
22
+ const createLayout = (
23
+ rows: Record<string, string[][]>,
24
+ sizes: Record<string, number[]>,
25
+ rowOrder?: string[],
26
+ ): GridLayoutData => ({
23
27
  rows,
24
28
  sizes,
29
+ rowOrder,
25
30
  });
26
31
 
27
32
  describe('getSlotKey', () => {
@@ -275,6 +280,7 @@ describe('simulateLayoutForSlot', () => {
275
280
  rowA: [24],
276
281
  rowB: [24],
277
282
  },
283
+ ['rowA', 'rowB'],
278
284
  );
279
285
 
280
286
  const slot: LayoutSlot = {
@@ -315,6 +321,33 @@ describe('simulateLayoutForSlot', () => {
315
321
  expect(result.sizes['row-new']).toEqual([24]);
316
322
  });
317
323
 
324
+ it('removes empty source row when moving item into empty container slot', () => {
325
+ const layout = createLayout(
326
+ {
327
+ rowA: [['block-x']],
328
+ },
329
+ {
330
+ rowA: [24],
331
+ },
332
+ );
333
+
334
+ const slot: LayoutSlot = {
335
+ type: 'empty-row',
336
+ rect,
337
+ };
338
+
339
+ const result = simulateLayoutForSlot({
340
+ slot,
341
+ sourceUid: 'block-x',
342
+ layout,
343
+ generateRowId: () => 'row-new',
344
+ });
345
+
346
+ expect(result.rows['row-new']).toEqual([['block-x']]);
347
+ expect(result.rows.rowA).toBeUndefined();
348
+ expect(result.sizes.rowA).toBeUndefined();
349
+ });
350
+
318
351
  it('handles column slot with after position', () => {
319
352
  const layout = createLayout(
320
353
  {
@@ -373,6 +406,7 @@ describe('simulateLayoutForSlot', () => {
373
406
  rowA: [24],
374
407
  rowB: [24],
375
408
  },
409
+ ['rowA', 'rowB'],
376
410
  );
377
411
 
378
412
  const slot: LayoutSlot = {
@@ -392,6 +426,112 @@ describe('simulateLayoutForSlot', () => {
392
426
  expect(Object.keys(result.rows)).toEqual(['rowA', 'row-inserted', 'rowB']);
393
427
  });
394
428
 
429
+ it('inserts row into rowOrder when dropping below target row', () => {
430
+ const layout = createLayout(
431
+ {
432
+ rowA: [['a']],
433
+ rowB: [['b']],
434
+ },
435
+ {
436
+ rowA: [24],
437
+ rowB: [24],
438
+ },
439
+ ['rowA', 'rowB'],
440
+ );
441
+
442
+ const slot: LayoutSlot = {
443
+ type: 'row-gap',
444
+ targetRowId: 'rowA',
445
+ position: 'below',
446
+ rect,
447
+ };
448
+
449
+ const result = simulateLayoutForSlot({
450
+ slot,
451
+ sourceUid: 'c',
452
+ layout,
453
+ generateRowId: () => 'row-new',
454
+ });
455
+
456
+ expect(result.rowOrder).toEqual(['rowA', 'row-new', 'rowB']);
457
+ });
458
+
459
+ it('maintains rowOrder and inserts new row before target when provided', () => {
460
+ const layout = createLayout(
461
+ {
462
+ rowA: [['a']],
463
+ rowB: [['b']],
464
+ },
465
+ {
466
+ rowA: [24],
467
+ rowB: [24],
468
+ },
469
+ ['rowA', 'rowB'],
470
+ );
471
+
472
+ const slot: LayoutSlot = {
473
+ type: 'row-gap',
474
+ targetRowId: 'rowB',
475
+ position: 'above',
476
+ rect,
477
+ };
478
+
479
+ const result = simulateLayoutForSlot({
480
+ slot,
481
+ sourceUid: 'c',
482
+ layout,
483
+ generateRowId: () => 'row-new',
484
+ });
485
+
486
+ expect(result.rowOrder).toEqual(['rowA', 'row-new', 'rowB']);
487
+ expect(result.rows).toEqual({
488
+ rowA: [['a']],
489
+ 'row-new': [['c']],
490
+ rowB: [['b']],
491
+ });
492
+ expect(result.sizes).toEqual({
493
+ rowA: [24],
494
+ 'row-new': [24],
495
+ rowB: [24],
496
+ });
497
+ });
498
+
499
+ it('derives rowOrder from rows when missing and removes empty rows from order', () => {
500
+ const layout = createLayout(
501
+ {
502
+ row1: [['a']],
503
+ row2: [['b']],
504
+ row3: [['c']],
505
+ },
506
+ {
507
+ row1: [24],
508
+ row2: [24],
509
+ row3: [24],
510
+ },
511
+ );
512
+
513
+ const slot: LayoutSlot = {
514
+ type: 'column',
515
+ rowId: 'row1',
516
+ columnIndex: 0,
517
+ insertIndex: 0,
518
+ position: 'before',
519
+ rect,
520
+ };
521
+
522
+ const result = simulateLayoutForSlot({ slot, sourceUid: 'b', layout });
523
+
524
+ expect(result.rowOrder).toEqual(['row1', 'row3']);
525
+ expect(result.rows).toEqual({
526
+ row1: [['b', 'a']],
527
+ row3: [['c']],
528
+ });
529
+ expect(result.sizes).toEqual({
530
+ row1: [24],
531
+ row3: [24],
532
+ });
533
+ });
534
+
395
535
  it('handles empty-column slot by replacing empty column', () => {
396
536
  const layout = createLayout(
397
537
  {
@@ -46,6 +46,7 @@ export interface Point {
46
46
  export interface GridLayoutData {
47
47
  rows: Record<string, string[][]>;
48
48
  sizes: Record<string, number[]>;
49
+ rowOrder?: string[];
49
50
  }
50
51
 
51
52
  export interface ColumnSlot {
@@ -142,6 +143,49 @@ export interface LayoutSnapshot {
142
143
  containerRect: Rect;
143
144
  }
144
145
 
146
+ const deriveRowOrder = (rows: Record<string, string[][]>, provided?: string[]) => {
147
+ const order: string[] = [];
148
+ const used = new Set<string>();
149
+
150
+ (provided || Object.keys(rows)).forEach((rowId) => {
151
+ if (rows[rowId] && !used.has(rowId)) {
152
+ order.push(rowId);
153
+ used.add(rowId);
154
+ }
155
+ });
156
+
157
+ Object.keys(rows).forEach((rowId) => {
158
+ if (!used.has(rowId)) {
159
+ order.push(rowId);
160
+ used.add(rowId);
161
+ }
162
+ });
163
+
164
+ return order;
165
+ };
166
+
167
+ const normalizeRowsWithOrder = (rows: Record<string, string[][]>, order: string[]) => {
168
+ const next: Record<string, string[][]> = {};
169
+ order.forEach((rowId) => {
170
+ if (rows[rowId]) {
171
+ next[rowId] = rows[rowId];
172
+ }
173
+ });
174
+ Object.keys(rows).forEach((rowId) => {
175
+ if (!next[rowId]) {
176
+ next[rowId] = rows[rowId];
177
+ }
178
+ });
179
+ return next;
180
+ };
181
+
182
+ const ensureRowOrder = (layout: GridLayoutData) => {
183
+ const order = deriveRowOrder(layout.rows, layout.rowOrder);
184
+ layout.rowOrder = order;
185
+ layout.rows = normalizeRowsWithOrder(layout.rows, order);
186
+ return order;
187
+ };
188
+
145
189
  export interface BuildLayoutSnapshotOptions {
146
190
  container: HTMLElement | null;
147
191
  }
@@ -465,10 +509,12 @@ const removeItemFromLayout = (layout: GridLayoutData, uidValue: string) => {
465
509
  if (columns.length === 0) {
466
510
  delete layout.rows[rowId];
467
511
  delete layout.sizes[rowId];
512
+ ensureRowOrder(layout);
468
513
  return;
469
514
  }
470
515
 
471
516
  normalizeRowSizes(rowId, layout);
517
+ ensureRowOrder(layout);
472
518
  };
473
519
 
474
520
  const toIntSizes = (weights: number[], count: number): number[] => {
@@ -592,8 +638,10 @@ export const simulateLayoutForSlot = ({
592
638
  const cloned: GridLayoutData = {
593
639
  rows: _.cloneDeep(layout.rows),
594
640
  sizes: _.cloneDeep(layout.sizes),
641
+ rowOrder: layout.rowOrder ? [...layout.rowOrder] : undefined,
595
642
  };
596
643
 
644
+ ensureRowOrder(cloned);
597
645
  removeItemFromLayout(cloned, sourceUid);
598
646
 
599
647
  const createRowId = generateRowId ?? uid;
@@ -638,8 +686,16 @@ export const simulateLayoutForSlot = ({
638
686
  case 'row-gap': {
639
687
  const newRowId = createRowId();
640
688
  const rowPosition: 'before' | 'after' = slot.position === 'above' ? 'before' : 'after';
689
+ const currentOrder = deriveRowOrder(cloned.rows, cloned.rowOrder);
641
690
  cloned.rows = insertRow(cloned.rows, slot.targetRowId, newRowId, rowPosition, [[sourceUid]]);
642
691
  cloned.sizes[newRowId] = [DEFAULT_GRID_COLUMNS];
692
+ const targetIndex = currentOrder.indexOf(slot.targetRowId);
693
+ const insertIndex =
694
+ targetIndex === -1 ? currentOrder.length : rowPosition === 'before' ? targetIndex : targetIndex + 1;
695
+ const nextOrder = [...currentOrder];
696
+ nextOrder.splice(insertIndex, 0, newRowId);
697
+ cloned.rowOrder = nextOrder;
698
+ cloned.rows = normalizeRowsWithOrder(cloned.rows, nextOrder);
643
699
  break;
644
700
  }
645
701
  case 'empty-row': {
@@ -649,11 +705,15 @@ export const simulateLayoutForSlot = ({
649
705
  [newRowId]: [[sourceUid]],
650
706
  };
651
707
  cloned.sizes[newRowId] = [DEFAULT_GRID_COLUMNS];
708
+ const currentOrder = deriveRowOrder(cloned.rows, cloned.rowOrder);
709
+ cloned.rowOrder = [...currentOrder.filter((id) => id !== newRowId), newRowId];
710
+ cloned.rows = normalizeRowsWithOrder(cloned.rows, cloned.rowOrder);
652
711
  break;
653
712
  }
654
713
  default:
655
714
  break;
656
715
  }
657
716
 
717
+ ensureRowOrder(cloned);
658
718
  return cloned;
659
719
  };
@@ -52,7 +52,8 @@ export const SwitchWithTitle: FC = observer(
52
52
  };
53
53
 
54
54
  // 点击整个容器时触发
55
- const handleWrapperClick = () => {
55
+ const handleWrapperClick = (e: React.MouseEvent) => {
56
+ e.stopPropagation();
56
57
  if (disabled) return;
57
58
  handleChange(!checked);
58
59
  };
@@ -0,0 +1,74 @@
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 React from 'react';
11
+ import { describe, it, expect, vi } from 'vitest';
12
+ import { render, screen, fireEvent } from '@nocobase/test/client';
13
+ import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
14
+
15
+ import { SwitchWithTitle } from '../SwitchWithTitle';
16
+ import { SelectWithTitle } from '../SelectWithTitle';
17
+
18
+ vi.mock('antd', async (importOriginal) => {
19
+ const actual = (await importOriginal()) as any;
20
+ return {
21
+ ...actual,
22
+ Select: ({
23
+ popupMatchSelectWidth,
24
+ bordered,
25
+ popupClassName,
26
+ fieldNames,
27
+ labelRender,
28
+ optionRender,
29
+ dropdownRender,
30
+ options,
31
+ ...props
32
+ }: any) => React.createElement('select', props),
33
+ Switch: ({ checkedChildren, unCheckedChildren, size, ...props }: any) =>
34
+ React.createElement('input', { ...props, type: 'checkbox', readOnly: true }),
35
+ };
36
+ });
37
+
38
+ describe('Inline controls - stopPropagation', () => {
39
+ it('SwitchWithTitle click does not bubble to parent', async () => {
40
+ const engine = new FlowEngine();
41
+ const parentClick = vi.fn();
42
+ const onChange = vi.fn();
43
+
44
+ render(
45
+ <FlowEngineProvider engine={engine}>
46
+ <div onClick={parentClick}>
47
+ <SwitchWithTitle title="Enabled" itemKey="enabled" onChange={onChange} />
48
+ </div>
49
+ </FlowEngineProvider>,
50
+ );
51
+
52
+ fireEvent.click(screen.getByText('Enabled'));
53
+
54
+ expect(parentClick).not.toHaveBeenCalled();
55
+ expect(onChange).toHaveBeenCalledWith({ enabled: true });
56
+ });
57
+
58
+ it('SelectWithTitle click does not bubble to parent', async () => {
59
+ const engine = new FlowEngine();
60
+ const parentClick = vi.fn();
61
+
62
+ render(
63
+ <FlowEngineProvider engine={engine}>
64
+ <div onClick={parentClick}>
65
+ <SelectWithTitle title="Mode" itemKey="mode" options={[{ label: 'A', value: 'a' }]} />
66
+ </div>
67
+ </FlowEngineProvider>,
68
+ );
69
+
70
+ fireEvent.click(screen.getByText('Mode'));
71
+
72
+ expect(parentClick).not.toHaveBeenCalled();
73
+ });
74
+ });