@nocobase/flow-engine 2.1.0-beta.9 → 2.2.0-beta.1

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 (215) hide show
  1. package/lib/FlowContextProvider.d.ts +5 -1
  2. package/lib/FlowContextProvider.js +9 -2
  3. package/lib/components/FieldModelRenderer.js +2 -2
  4. package/lib/components/FlowModelRenderer.d.ts +3 -1
  5. package/lib/components/FlowModelRenderer.js +12 -6
  6. package/lib/components/FormItem.d.ts +6 -0
  7. package/lib/components/FormItem.js +11 -3
  8. package/lib/components/MobilePopup.js +6 -5
  9. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  10. package/lib/components/dnd/gridDragPlanner.js +607 -19
  11. package/lib/components/dnd/index.d.ts +31 -2
  12. package/lib/components/dnd/index.js +244 -23
  13. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  14. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  15. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  16. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +152 -42
  17. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  18. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  19. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  20. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  21. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  22. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  23. package/lib/components/subModel/AddSubModelButton.js +12 -1
  24. package/lib/components/subModel/LazyDropdown.js +301 -52
  25. package/lib/components/subModel/index.d.ts +1 -0
  26. package/lib/components/subModel/index.js +19 -0
  27. package/lib/components/subModel/utils.d.ts +2 -1
  28. package/lib/components/subModel/utils.js +15 -5
  29. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  30. package/lib/components/variables/VariableHybridInput.js +499 -0
  31. package/lib/components/variables/index.d.ts +2 -0
  32. package/lib/components/variables/index.js +3 -0
  33. package/lib/data-source/index.d.ts +84 -0
  34. package/lib/data-source/index.js +269 -7
  35. package/lib/executor/FlowExecutor.js +6 -3
  36. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  37. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  38. package/lib/flow-registry/index.d.ts +1 -0
  39. package/lib/flow-registry/index.js +3 -1
  40. package/lib/flowContext.d.ts +9 -1
  41. package/lib/flowContext.js +77 -6
  42. package/lib/flowEngine.d.ts +136 -4
  43. package/lib/flowEngine.js +429 -51
  44. package/lib/flowI18n.js +2 -1
  45. package/lib/flowSettings.d.ts +14 -6
  46. package/lib/flowSettings.js +34 -6
  47. package/lib/index.d.ts +2 -0
  48. package/lib/index.js +7 -0
  49. package/lib/lazy-helper.d.ts +14 -0
  50. package/lib/lazy-helper.js +71 -0
  51. package/lib/locale/en-US.json +1 -0
  52. package/lib/locale/index.d.ts +2 -0
  53. package/lib/locale/zh-CN.json +1 -0
  54. package/lib/models/DisplayItemModel.d.ts +1 -1
  55. package/lib/models/EditableItemModel.d.ts +1 -1
  56. package/lib/models/FilterableItemModel.d.ts +1 -1
  57. package/lib/models/flowModel.d.ts +13 -10
  58. package/lib/models/flowModel.js +126 -34
  59. package/lib/provider.js +38 -23
  60. package/lib/reactive/observer.js +46 -16
  61. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
  62. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
  63. package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
  64. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
  65. package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
  66. package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
  67. package/lib/runjs-context/contexts/base.js +464 -29
  68. package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
  69. package/lib/runjs-context/contexts/elementDoc.js +152 -0
  70. package/lib/runjs-context/setup.js +1 -0
  71. package/lib/runjs-context/snippets/index.js +13 -2
  72. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  73. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  74. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  75. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  76. package/lib/types.d.ts +50 -2
  77. package/lib/types.js +1 -0
  78. package/lib/utils/createCollectionContextMeta.js +6 -2
  79. package/lib/utils/index.d.ts +3 -2
  80. package/lib/utils/index.js +7 -0
  81. package/lib/utils/loadedPageCache.d.ts +24 -0
  82. package/lib/utils/loadedPageCache.js +139 -0
  83. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  84. package/lib/utils/parsePathnameToViewParams.js +28 -4
  85. package/lib/utils/randomId.d.ts +39 -0
  86. package/lib/utils/randomId.js +45 -0
  87. package/lib/utils/runjsTemplateCompat.js +1 -1
  88. package/lib/utils/runjsValue.js +41 -11
  89. package/lib/utils/schema-utils.d.ts +7 -1
  90. package/lib/utils/schema-utils.js +19 -0
  91. package/lib/views/FlowView.d.ts +7 -1
  92. package/lib/views/FlowView.js +11 -1
  93. package/lib/views/PageComponent.js +8 -6
  94. package/lib/views/ViewNavigation.d.ts +12 -2
  95. package/lib/views/ViewNavigation.js +28 -9
  96. package/lib/views/createViewMeta.js +114 -50
  97. package/lib/views/inheritLayoutContext.d.ts +10 -0
  98. package/lib/views/inheritLayoutContext.js +50 -0
  99. package/lib/views/runViewBeforeClose.d.ts +10 -0
  100. package/lib/views/runViewBeforeClose.js +45 -0
  101. package/lib/views/useDialog.d.ts +2 -1
  102. package/lib/views/useDialog.js +12 -3
  103. package/lib/views/useDrawer.d.ts +2 -1
  104. package/lib/views/useDrawer.js +12 -3
  105. package/lib/views/usePage.d.ts +5 -11
  106. package/lib/views/usePage.js +304 -144
  107. package/package.json +5 -4
  108. package/src/FlowContextProvider.tsx +9 -1
  109. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  110. package/src/__tests__/flow-engine.test.ts +166 -0
  111. package/src/__tests__/flowContext.test.ts +105 -1
  112. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  113. package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
  114. package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
  115. package/src/__tests__/flowSettings.test.ts +94 -15
  116. package/src/__tests__/objectVariable.test.ts +24 -0
  117. package/src/__tests__/provider.test.tsx +24 -2
  118. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  119. package/src/__tests__/runjsContext.test.ts +21 -0
  120. package/src/__tests__/runjsContextImplementations.test.ts +9 -2
  121. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  122. package/src/__tests__/runjsLocales.test.ts +6 -5
  123. package/src/__tests__/runjsSnippets.test.ts +21 -0
  124. package/src/__tests__/viewScopedFlowEngine.test.ts +136 -3
  125. package/src/components/FieldModelRenderer.tsx +2 -1
  126. package/src/components/FlowModelRenderer.tsx +18 -6
  127. package/src/components/FormItem.tsx +7 -1
  128. package/src/components/MobilePopup.tsx +4 -2
  129. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  130. package/src/components/__tests__/FormItem.test.tsx +25 -0
  131. package/src/components/__tests__/dnd.test.ts +44 -0
  132. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  133. package/src/components/__tests__/gridDragPlanner.test.ts +472 -5
  134. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  135. package/src/components/dnd/gridDragPlanner.ts +750 -17
  136. package/src/components/dnd/index.tsx +305 -28
  137. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  138. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +178 -48
  139. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  140. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +344 -8
  141. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  142. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  143. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  144. package/src/components/subModel/AddSubModelButton.tsx +16 -2
  145. package/src/components/subModel/LazyDropdown.tsx +341 -56
  146. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +524 -38
  147. package/src/components/subModel/__tests__/utils.test.ts +24 -0
  148. package/src/components/subModel/index.ts +1 -0
  149. package/src/components/subModel/utils.ts +13 -2
  150. package/src/components/variables/VariableHybridInput.tsx +531 -0
  151. package/src/components/variables/index.ts +2 -0
  152. package/src/data-source/__tests__/collection.test.ts +41 -2
  153. package/src/data-source/__tests__/index.test.ts +69 -2
  154. package/src/data-source/index.ts +332 -8
  155. package/src/executor/FlowExecutor.ts +6 -3
  156. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  157. package/src/flow-registry/DetachedFlowRegistry.ts +46 -0
  158. package/src/flow-registry/__tests__/detachedFlowRegistry.test.ts +47 -0
  159. package/src/flow-registry/index.ts +1 -0
  160. package/src/flowContext.ts +85 -6
  161. package/src/flowEngine.ts +484 -45
  162. package/src/flowI18n.ts +2 -1
  163. package/src/flowSettings.ts +40 -6
  164. package/src/index.ts +2 -0
  165. package/src/lazy-helper.tsx +57 -0
  166. package/src/locale/en-US.json +1 -0
  167. package/src/locale/zh-CN.json +1 -0
  168. package/src/models/DisplayItemModel.tsx +1 -1
  169. package/src/models/EditableItemModel.tsx +1 -1
  170. package/src/models/FilterableItemModel.tsx +1 -1
  171. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  172. package/src/models/__tests__/flowModel.test.ts +65 -37
  173. package/src/models/flowModel.tsx +184 -65
  174. package/src/provider.tsx +41 -25
  175. package/src/reactive/__tests__/observer.test.tsx +82 -0
  176. package/src/reactive/observer.tsx +87 -25
  177. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
  178. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
  179. package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
  180. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
  181. package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
  182. package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
  183. package/src/runjs-context/contexts/base.ts +467 -31
  184. package/src/runjs-context/contexts/elementDoc.ts +130 -0
  185. package/src/runjs-context/setup.ts +1 -0
  186. package/src/runjs-context/snippets/index.ts +12 -1
  187. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  188. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  189. package/src/types.ts +62 -0
  190. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  191. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
  192. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  193. package/src/utils/__tests__/utils.test.ts +62 -0
  194. package/src/utils/createCollectionContextMeta.ts +6 -2
  195. package/src/utils/index.ts +5 -1
  196. package/src/utils/loadedPageCache.ts +147 -0
  197. package/src/utils/parsePathnameToViewParams.ts +45 -5
  198. package/src/utils/randomId.ts +48 -0
  199. package/src/utils/runjsTemplateCompat.ts +1 -1
  200. package/src/utils/runjsValue.ts +50 -11
  201. package/src/utils/schema-utils.ts +30 -1
  202. package/src/views/FlowView.tsx +22 -2
  203. package/src/views/PageComponent.tsx +7 -4
  204. package/src/views/ViewNavigation.ts +46 -9
  205. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  206. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  207. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  208. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  209. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +12 -12
  210. package/src/views/createViewMeta.ts +106 -34
  211. package/src/views/inheritLayoutContext.ts +26 -0
  212. package/src/views/runViewBeforeClose.ts +19 -0
  213. package/src/views/useDialog.tsx +13 -3
  214. package/src/views/useDrawer.tsx +13 -3
  215. package/src/views/usePage.tsx +367 -180
@@ -100,6 +100,30 @@ describe('subModel/utils', () => {
100
100
  expect(groups[0].children).toBeTruthy();
101
101
  });
102
102
 
103
+ it('preserves searchable meta on generated groups', async () => {
104
+ const engine = new FlowEngine();
105
+
106
+ class Base extends FlowModel {}
107
+ Base.define({
108
+ label: 'Base Group',
109
+ searchable: true,
110
+ searchPlaceholder: 'Search fields',
111
+ });
112
+ const BaseDC = attachDefineChildren(Base, async () => [{ key: 'title', label: 'Title' }]);
113
+
114
+ engine.registerModels({ Base: BaseDC });
115
+
116
+ const model = engine.createModel({ use: 'FlowModel' });
117
+ const ctx = model.context;
118
+
119
+ const groupsFactory = buildSubModelGroups([BaseDC]);
120
+ const groups = await groupsFactory(ctx);
121
+
122
+ expect(groups).toHaveLength(1);
123
+ expect(groups[0].searchable).toBe(true);
124
+ expect(groups[0].searchPlaceholder).toBe('Search fields');
125
+ });
126
+
103
127
  it('invokes buildSubModelItems when meta.children is false', async () => {
104
128
  const engine = new FlowEngine();
105
129
 
@@ -8,5 +8,6 @@
8
8
  */
9
9
 
10
10
  export * from './AddSubModelButton';
11
+ export { default as LazyDropdown } from './LazyDropdown';
11
12
  export * from './utils';
12
13
  //
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import * as _ from 'lodash';
10
+ import _ from 'lodash';
11
11
  import type { Collection } from '../../data-source';
12
12
  import { FlowModelContext } from '../../flowContext';
13
13
  import { FlowModelMeta, ModelConstructor } from '../../types';
@@ -196,12 +196,16 @@ export function buildSubModelGroups(subModelBaseClasses: (string | ModelConstruc
196
196
  const baseKey = typeof subModelBaseClass === 'string' ? subModelBaseClass : BaseClass.name;
197
197
  const menuType = BaseClass?.meta?.menuType || 'group';
198
198
  const groupSort = BaseClass?.meta?.sort ?? 1000;
199
+ const searchable = !!BaseClass?.meta?.searchable;
200
+ const searchPlaceholder = BaseClass?.meta?.searchPlaceholder;
199
201
  if (menuType === 'submenu') {
200
202
  // 作为可点击的一级项,展开二级子菜单
201
203
  items.push({
202
204
  key: baseKey,
203
205
  label: groupLabel,
204
206
  sort: groupSort,
207
+ searchable,
208
+ searchPlaceholder,
205
209
  children,
206
210
  });
207
211
  } else {
@@ -211,6 +215,8 @@ export function buildSubModelGroups(subModelBaseClasses: (string | ModelConstruc
211
215
  type: 'group',
212
216
  label: groupLabel,
213
217
  sort: groupSort,
218
+ searchable,
219
+ searchPlaceholder,
214
220
  children,
215
221
  });
216
222
  }
@@ -226,6 +232,7 @@ export interface BuildFieldChildrenOptions {
226
232
  fieldUseModel?: string | ((field: any) => string);
227
233
  collection?: Collection;
228
234
  associationPathName?: string;
235
+ maxAssociationFieldDepth?: number;
229
236
  /**
230
237
  * 点击这些子项后,除自身路径外,还需要联动刷新的其他菜单路径前缀
231
238
  */
@@ -233,13 +240,17 @@ export interface BuildFieldChildrenOptions {
233
240
  }
234
241
 
235
242
  export function buildWrapperFieldChildren(ctx: FlowModelContext, options: BuildFieldChildrenOptions) {
236
- const { useModel, fieldUseModel, associationPathName, refreshTargets } = options;
243
+ const { useModel, fieldUseModel, associationPathName, refreshTargets, maxAssociationFieldDepth = 2 } = options;
237
244
  const collection: Collection = options.collection || ctx.model['collection'] || ctx.collection;
238
245
  const fields = collection.getFields();
239
246
  const defaultItemKeys = ['fieldSettings', 'init'];
240
247
  const children: SubModelItem[] = [];
248
+ const associationDepth = associationPathName ? associationPathName.split('.').filter(Boolean).length : 0;
241
249
  for (const f of fields) {
242
250
  if (!f?.options?.interface) continue;
251
+ if (associationDepth >= maxAssociationFieldDepth && (f.isAssociationField?.() || f.target || f.targetCollection)) {
252
+ continue;
253
+ }
243
254
  const fieldPath = associationPathName ? `${associationPathName}.${f.name}` : f.name;
244
255
 
245
256
  const childUse = typeof fieldUseModel === 'function' ? fieldUseModel(f) : fieldUseModel ?? 'FieldModel';
@@ -0,0 +1,531 @@
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 { css, cx } from '@emotion/css';
11
+ import { Space, theme } from 'antd';
12
+ import React, { isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
+ import type { MetaTreeNode } from '../../flowContext';
14
+ import { useFlowContext } from '../../FlowContextProvider';
15
+ import { FlowContextSelector } from '../FlowContextSelector';
16
+ import { useResolvedMetaTree } from './useResolvedMetaTree';
17
+ import { formatPathToValue as defaultFormatPathToValue, parseValueToPath as defaultParseValueToPath } from './utils';
18
+
19
+ type RangeIndexes = [number, number, number, number];
20
+
21
+ const DEFAULT_VARIABLE_REGEXP = /\{\{\s*([^{}]+?)\s*\}\}/g;
22
+ const TAG_CLASS = 'nb-variable-tag';
23
+
24
+ export interface VariableHybridInputConverters {
25
+ formatPathToValue?: (item?: MetaTreeNode) => string | undefined;
26
+ parseValueToPath?: (value?: string) => string[] | undefined;
27
+ variableRegExp?: RegExp;
28
+ }
29
+
30
+ export interface VariableHybridInputProps {
31
+ value?: string;
32
+ onChange?: (value: string) => void;
33
+ disabled?: boolean;
34
+ placeholder?: string;
35
+ addonBefore?: React.ReactNode;
36
+ metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
37
+ converters?: VariableHybridInputConverters;
38
+ style?: React.CSSProperties;
39
+ className?: string;
40
+ }
41
+
42
+ function reactNodeToPlainText(node: React.ReactNode): string {
43
+ if (node == null || typeof node === 'boolean') return '';
44
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
45
+ if (Array.isArray(node)) return node.map(reactNodeToPlainText).join('');
46
+ if (isValidElement(node)) return reactNodeToPlainText(node.props.children);
47
+ return '';
48
+ }
49
+
50
+ function escapeHtml(value: string) {
51
+ return value
52
+ .replace(/&/g, '&amp;')
53
+ .replace(/</g, '&lt;')
54
+ .replace(/>/g, '&gt;')
55
+ .replace(/"/g, '&quot;')
56
+ .replace(/'/g, '&#039;');
57
+ }
58
+
59
+ function getDomValue(element: HTMLElement) {
60
+ const out: string[] = [];
61
+ for (const node of Array.from(element.childNodes)) {
62
+ if (node instanceof HTMLElement && node.dataset.variable) {
63
+ out.push(node.dataset.variable);
64
+ } else {
65
+ out.push(node.textContent || '');
66
+ }
67
+ }
68
+ return out.join('');
69
+ }
70
+
71
+ function createTagHTML(variable: string, label: string) {
72
+ return `<span class="${TAG_CLASS}" contenteditable="false" data-variable="${escapeHtml(
73
+ variable,
74
+ )}" title="${escapeHtml(label)}">${escapeHtml(label)}</span>`;
75
+ }
76
+
77
+ // Strip outer `{{ }}` and trim, so values like `{{ $env.x }}` and `{{$env.x}}`
78
+ // share the same lookup key.
79
+ function normalizeVariableKey(value: string): string {
80
+ return value
81
+ .replace(/^\{\{\s*/, '')
82
+ .replace(/\s*\}\}$/, '')
83
+ .trim();
84
+ }
85
+
86
+ function renderHTML(value: string, labelMap: Map<string, string>, regExp: RegExp) {
87
+ const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
88
+ return escapeHtml(value || '').replace(re, (matched) => {
89
+ const label = labelMap.get(normalizeVariableKey(matched)) || matched;
90
+ return createTagHTML(matched, label);
91
+ });
92
+ }
93
+
94
+ function buildLabelMap(
95
+ nodes: MetaTreeNode[] | undefined,
96
+ ctxT: (text: string) => string,
97
+ converters?: VariableHybridInputConverters,
98
+ ) {
99
+ const map = new Map<string, string>();
100
+
101
+ function walk(items: MetaTreeNode[] = [], parentTitles: string[] = []) {
102
+ for (const item of items) {
103
+ const titlePart = reactNodeToPlainText(item.title || item.name);
104
+ const titles = [...parentTitles, titlePart];
105
+ const value = converters?.formatPathToValue?.(item) ?? defaultFormatPathToValue(item);
106
+ if (value) {
107
+ map.set(normalizeVariableKey(value), titles.map(ctxT).join('/'));
108
+ }
109
+ if (Array.isArray(item.children)) {
110
+ walk(item.children as MetaTreeNode[], titles);
111
+ }
112
+ }
113
+ }
114
+
115
+ walk(nodes);
116
+ return map;
117
+ }
118
+
119
+ function pasteHTML(container: HTMLElement, html: string, indexes?: RangeIndexes) {
120
+ const selection = window.getSelection?.();
121
+ const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
122
+ if (!range) return;
123
+
124
+ if (indexes) {
125
+ const children = Array.from(container.childNodes);
126
+ if (indexes[0] === -1) {
127
+ if (indexes[1] && children[indexes[1] - 1]) {
128
+ range.setStartAfter(children[indexes[1] - 1]);
129
+ } else {
130
+ range.setStart(container, 0);
131
+ }
132
+ } else {
133
+ range.setStart(children[indexes[0]], indexes[1]);
134
+ }
135
+
136
+ if (indexes[2] === -1) {
137
+ if (indexes[3] && children[indexes[3] - 1]) {
138
+ range.setEndAfter(children[indexes[3] - 1]);
139
+ } else {
140
+ range.setEnd(container, 0);
141
+ }
142
+ } else {
143
+ range.setEnd(children[indexes[2]], indexes[3]);
144
+ }
145
+ }
146
+
147
+ const wrapper = document.createElement('div');
148
+ wrapper.innerHTML = html;
149
+ const fragment = document.createDocumentFragment();
150
+ let lastNode: ChildNode | null = null;
151
+ while (wrapper.firstChild) {
152
+ lastNode = fragment.appendChild(wrapper.firstChild);
153
+ }
154
+ range.deleteContents();
155
+ range.insertNode(fragment);
156
+
157
+ if (lastNode) {
158
+ const next = new Range();
159
+ next.setStartAfter(lastNode);
160
+ next.collapse(true);
161
+ selection?.removeAllRanges();
162
+ selection?.addRange(next);
163
+ }
164
+ }
165
+
166
+ function getSingleEndRange(nodes: ChildNode[], index: number, offset: number): [number, number] {
167
+ if (index === -1) {
168
+ let realIndex = offset;
169
+ let collapseFlag = false;
170
+ if (realIndex && nodes[realIndex - 1]?.nodeName === '#text' && nodes[realIndex]?.nodeName === '#text') {
171
+ collapseFlag = true;
172
+ }
173
+ let textOffset = 0;
174
+ for (let i = offset - 1; i >= 0; i -= 1) {
175
+ if (collapseFlag) {
176
+ if (nodes[i]?.nodeName === '#text') {
177
+ textOffset += nodes[i].textContent?.length || 0;
178
+ } else {
179
+ collapseFlag = false;
180
+ }
181
+ }
182
+ if (nodes[i]?.nodeName === '#text' && nodes[i + 1]?.nodeName === '#text') {
183
+ realIndex -= 1;
184
+ }
185
+ }
186
+ return textOffset ? [realIndex, textOffset] : [-1, realIndex];
187
+ }
188
+
189
+ let realIndex = 0;
190
+ let textOffset = 0;
191
+ for (let i = 0; i < index + 1; i += 1) {
192
+ if (nodes[i]?.nodeName === '#text') {
193
+ if (i !== index && nodes[i + 1] && nodes[i + 1]?.nodeName !== '#text') {
194
+ realIndex += 1;
195
+ }
196
+ textOffset += i === index ? offset : nodes[i].textContent?.length || 0;
197
+ } else {
198
+ realIndex += 1;
199
+ textOffset = 0;
200
+ }
201
+ }
202
+ return [realIndex, textOffset];
203
+ }
204
+
205
+ function getCurrentRange(element: HTMLElement): RangeIndexes {
206
+ const selection = window.getSelection?.();
207
+ const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
208
+ if (!range || !element.contains(range.commonAncestorContainer)) {
209
+ return [-1, 0, -1, 0];
210
+ }
211
+
212
+ const nodes = Array.from(element.childNodes);
213
+ if (!nodes.length) return [-1, 0, -1, 0];
214
+
215
+ const startIndex = range.startContainer === element ? -1 : nodes.indexOf(range.startContainer as HTMLElement);
216
+ const endIndex = range.endContainer === element ? -1 : nodes.indexOf(range.endContainer as HTMLElement);
217
+
218
+ return [
219
+ ...getSingleEndRange(nodes, startIndex, range.startOffset),
220
+ ...getSingleEndRange(nodes, endIndex, range.endOffset),
221
+ ];
222
+ }
223
+
224
+ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props) => {
225
+ const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, style } = props;
226
+ const { token } = theme.useToken();
227
+ const ctx = useFlowContext();
228
+ const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
229
+ const inputRef = useRef<HTMLDivElement>(null);
230
+ const [isComposing, setIsComposing] = useState(false);
231
+ const [changed, setChanged] = useState(false);
232
+ const [range, setRange] = useState<RangeIndexes>([-1, 0, -1, 0]);
233
+
234
+ const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : String(props.value);
235
+ const variableRegExp = converters?.variableRegExp ?? DEFAULT_VARIABLE_REGEXP;
236
+
237
+ const labelMap = useMemo(
238
+ () => buildLabelMap(resolvedMetaTree as MetaTreeNode[] | undefined, ctx.t, converters),
239
+ [resolvedMetaTree, ctx, converters],
240
+ );
241
+
242
+ const [html, setHtml] = useState(() => renderHTML(value, labelMap, variableRegExp));
243
+
244
+ const emitChange = useCallback(
245
+ (target: HTMLElement) => {
246
+ onChange?.(getDomValue(target).trim());
247
+ },
248
+ [onChange],
249
+ );
250
+
251
+ useEffect(() => {
252
+ setHtml(renderHTML(value, labelMap, variableRegExp));
253
+ if (!changed) {
254
+ setRange([-1, 0, -1, 0]);
255
+ }
256
+ // eslint-disable-next-line react-hooks/exhaustive-deps
257
+ }, [value, labelMap]);
258
+
259
+ // Restore caret position after html update
260
+ useEffect(() => {
261
+ const element = inputRef.current;
262
+ if (!element) return;
263
+ if (document.activeElement !== element) return;
264
+
265
+ const nextRange = new Range();
266
+ if (changed) {
267
+ if (range.join() === '-1,0,-1,0') return;
268
+ const selection = window.getSelection?.();
269
+ if (!selection) return;
270
+ try {
271
+ const children = Array.from(element.childNodes) as HTMLElement[];
272
+ if (children.length) {
273
+ if (range[0] === -1) {
274
+ if (range[1]) nextRange.setStartAfter(children[range[1] - 1]);
275
+ } else {
276
+ nextRange.setStart(children[range[0]], range[1]);
277
+ }
278
+ if (range[2] === -1) {
279
+ if (range[3]) nextRange.setEndAfter(children[range[3] - 1]);
280
+ } else {
281
+ nextRange.setEnd(children[range[2]], range[3]);
282
+ }
283
+ }
284
+ nextRange.collapse(true);
285
+ selection.removeAllRanges();
286
+ selection.addRange(nextRange);
287
+ } catch {
288
+ /* ignore */
289
+ }
290
+ } else {
291
+ const { lastChild } = element;
292
+ if (lastChild) {
293
+ nextRange.setStartAfter(lastChild);
294
+ nextRange.setEndAfter(lastChild);
295
+ const nodes = Array.from(element.childNodes);
296
+ const idx = nodes.indexOf(lastChild);
297
+ const startIndex = nextRange.startContainer === element ? -1 : idx;
298
+ const endIndex = nextRange.startContainer === element ? -1 : idx;
299
+ setRange([startIndex, nextRange.startOffset, endIndex, nextRange.endOffset]);
300
+ }
301
+ }
302
+ }, [changed, html, range]);
303
+
304
+ const insertVariable = useCallback(
305
+ (variable: string, meta?: MetaTreeNode) => {
306
+ const current = inputRef.current;
307
+ if (!current || !variable) return;
308
+
309
+ const label =
310
+ labelMap.get(normalizeVariableKey(variable)) ||
311
+ (meta
312
+ ? [...(meta.parentTitles || []), reactNodeToPlainText(meta.title || meta.name)].map(ctx.t).join('/')
313
+ : variable);
314
+
315
+ current.focus();
316
+ pasteHTML(current, createTagHTML(variable, label), range);
317
+ setChanged(true);
318
+ setRange(getCurrentRange(current));
319
+ emitChange(current);
320
+ },
321
+ [labelMap, range, emitChange, ctx],
322
+ );
323
+
324
+ const handleSelectorChange = useCallback(
325
+ (next: string, meta?: MetaTreeNode) => {
326
+ if (!next) return;
327
+ insertVariable(next, meta);
328
+ },
329
+ [insertVariable],
330
+ );
331
+
332
+ const handleInput = useCallback(
333
+ ({ currentTarget }: React.FormEvent<HTMLDivElement>) => {
334
+ if (isComposing) return;
335
+ setChanged(true);
336
+ setRange(getCurrentRange(currentTarget));
337
+ emitChange(currentTarget);
338
+ },
339
+ [emitChange, isComposing],
340
+ );
341
+
342
+ const handleBlur = useCallback(({ currentTarget }: React.FocusEvent<HTMLDivElement>) => {
343
+ setRange(getCurrentRange(currentTarget));
344
+ }, []);
345
+
346
+ const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
347
+ if (event.key === 'Enter') {
348
+ event.preventDefault();
349
+ }
350
+ }, []);
351
+
352
+ const handlePaste = useCallback(
353
+ (event: React.ClipboardEvent<HTMLDivElement>) => {
354
+ event.preventDefault();
355
+ // Paste as plain text only; variable tags must be inserted via the picker.
356
+ const text = event.clipboardData.getData('text/plain').replace(/\n/g, ' ');
357
+ if (!text) return;
358
+ setChanged(true);
359
+ pasteHTML(event.currentTarget, escapeHtml(text));
360
+ setRange(getCurrentRange(event.currentTarget));
361
+ emitChange(event.currentTarget);
362
+ },
363
+ [emitChange],
364
+ );
365
+
366
+ const handleCompositionStart = useCallback(() => setIsComposing(true), []);
367
+ const handleCompositionEnd = useCallback(
368
+ ({ currentTarget }: React.CompositionEvent<HTMLDivElement>) => {
369
+ setIsComposing(false);
370
+ setChanged(true);
371
+ setRange(getCurrentRange(currentTarget));
372
+ emitChange(currentTarget);
373
+ },
374
+ [emitChange],
375
+ );
376
+
377
+ const wrapperClassName = useMemo(
378
+ () => css`
379
+ display: flex;
380
+ width: 100%;
381
+ min-width: 0;
382
+
383
+ &.ant-space-compact {
384
+ display: flex;
385
+ }
386
+
387
+ /* The trigger button from FlowContextSelector sits at the right end.
388
+ Flatten its left corners so it shares the border with the editor. */
389
+ > .ant-btn {
390
+ flex-shrink: 0;
391
+ border-top-left-radius: 0;
392
+ border-bottom-left-radius: 0;
393
+ margin-left: -${token.lineWidth}px;
394
+ }
395
+
396
+ > .ant-btn:hover,
397
+ > .ant-btn:focus {
398
+ z-index: 2;
399
+ }
400
+ `,
401
+ [token.lineWidth],
402
+ );
403
+
404
+ const addonClassName = useMemo(
405
+ () => css`
406
+ display: inline-flex;
407
+ align-items: center;
408
+ padding: 0 ${token.paddingSM}px;
409
+ background: ${token.colorFillTertiary};
410
+ border: ${token.lineWidth}px ${token.lineType} ${token.colorBorder};
411
+ border-right: 0;
412
+ border-radius: ${token.borderRadius}px 0 0 ${token.borderRadius}px;
413
+ color: ${token.colorText};
414
+ font-size: ${token.fontSize}px;
415
+ line-height: 1;
416
+ white-space: nowrap;
417
+ `,
418
+ [token],
419
+ );
420
+
421
+ const editorClassName = useMemo(() => {
422
+ const verticalPad = Math.max(
423
+ 0,
424
+ (token.controlHeight - Math.round(token.lineHeight * token.fontSize)) / 2 - token.lineWidth,
425
+ );
426
+ return css`
427
+ flex: 1 1 auto;
428
+ min-width: 0;
429
+ min-height: ${token.controlHeight}px;
430
+ padding: ${verticalPad}px ${token.paddingSM}px;
431
+ overflow: hidden;
432
+ white-space: pre-wrap;
433
+ word-break: break-word;
434
+ line-height: ${token.lineHeight};
435
+ font-size: ${token.fontSize}px;
436
+ color: ${token.colorText};
437
+ background: ${token.colorBgContainer};
438
+ border: ${token.lineWidth}px ${token.lineType} ${token.colorBorder};
439
+ /* Right corners are always flat because the X picker button is glued to the right side. */
440
+ border-radius: ${addonBefore ? '0' : `${token.borderRadius}px 0 0 ${token.borderRadius}px`};
441
+ cursor: text;
442
+ transition: all ${token.motionDurationMid};
443
+ outline: none;
444
+
445
+ &:hover {
446
+ border-color: ${token.colorPrimaryHover};
447
+ z-index: 1;
448
+ }
449
+
450
+ &:focus,
451
+ &:focus-visible {
452
+ border-color: ${token.colorPrimary};
453
+ box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.controlOutline};
454
+ z-index: 1;
455
+ }
456
+
457
+ &[data-placeholder]:empty::before {
458
+ content: attr(data-placeholder);
459
+ color: ${token.colorTextPlaceholder};
460
+ pointer-events: none;
461
+ }
462
+
463
+ .${TAG_CLASS} {
464
+ /* inline lets long tag content wrap naturally across lines, mirroring v1. */
465
+ display: inline;
466
+ margin: 0 ${token.marginXXS}px;
467
+ padding: ${token.paddingXXS}px ${token.paddingXS}px;
468
+ font-size: ${token.fontSizeSM}px;
469
+ line-height: ${token.lineHeightSM};
470
+ color: ${token.colorPrimaryText};
471
+ background: ${token.colorPrimaryBg};
472
+ border: ${token.lineWidth}px ${token.lineType} ${token.colorPrimaryBorder};
473
+ border-radius: ${token.borderRadiusSM}px;
474
+ vertical-align: baseline;
475
+ user-select: none;
476
+ cursor: default;
477
+ }
478
+
479
+ &.is-disabled {
480
+ background: ${token.colorBgContainerDisabled};
481
+ color: ${token.colorTextDisabled};
482
+ border-color: ${token.colorBorder};
483
+ cursor: not-allowed;
484
+
485
+ &:hover {
486
+ border-color: ${token.colorBorder};
487
+ }
488
+
489
+ .${TAG_CLASS} {
490
+ color: ${token.colorTextDisabled};
491
+ background: ${token.colorFillTertiary};
492
+ border-color: ${token.colorBorder};
493
+ }
494
+ }
495
+ `;
496
+ }, [token, addonBefore]);
497
+
498
+ return (
499
+ <>
500
+ <Space.Compact className={cx('nb-variable-hybrid-input', wrapperClassName, className)} style={style}>
501
+ {addonBefore != null && <span className={addonClassName}>{addonBefore}</span>}
502
+ <div
503
+ ref={inputRef}
504
+ role="textbox"
505
+ aria-label="textbox"
506
+ className={cx(editorClassName, {
507
+ 'is-disabled': disabled,
508
+ })}
509
+ contentEditable={!disabled}
510
+ data-placeholder={placeholder}
511
+ onInput={handleInput}
512
+ onBlur={handleBlur}
513
+ onKeyDown={handleKeyDown}
514
+ onPaste={handlePaste}
515
+ onCompositionStart={handleCompositionStart}
516
+ onCompositionEnd={handleCompositionEnd}
517
+ dangerouslySetInnerHTML={{ __html: html }}
518
+ />
519
+ <FlowContextSelector
520
+ metaTree={metaTree}
521
+ disabled={disabled}
522
+ parseValueToPath={converters?.parseValueToPath ?? defaultParseValueToPath}
523
+ formatPathToValue={(item) => converters?.formatPathToValue?.(item) || defaultFormatPathToValue(item)}
524
+ onChange={handleSelectorChange}
525
+ />
526
+ </Space.Compact>
527
+ </>
528
+ );
529
+ };
530
+
531
+ export const VariableHybridInput = React.memo(VariableHybridInputComponent);
@@ -8,6 +8,8 @@
8
8
  */
9
9
 
10
10
  export { VariableInput } from './VariableInput';
11
+ export { VariableHybridInput } from './VariableHybridInput';
12
+ export type { VariableHybridInputProps, VariableHybridInputConverters } from './VariableHybridInput';
11
13
  export { SlateVariableEditor } from './SlateVariableEditor';
12
14
  export { VariableTag } from './VariableTag';
13
15
  export { InlineVariableTag } from './InlineVariableTag';
@@ -7,8 +7,8 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { describe, expect, it } from 'vitest';
11
- import { DataSource, DataSourceManager, isFieldInterfaceMatch } from '../index';
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import { DataSource, DataSourceManager, getCollectionFieldInterface, isFieldInterfaceMatch } from '../index';
12
12
  import { FlowEngine } from '../../flowEngine';
13
13
 
14
14
  describe('Collection/Field helpers', () => {
@@ -55,4 +55,43 @@ describe('Collection/Field helpers', () => {
55
55
  const field = posts.getFieldByPath('category.name');
56
56
  expect(field?.name).toBe('name');
57
57
  });
58
+
59
+ it('resolves collection field interfaces from the first available manager', () => {
60
+ const first = { collectionFieldInterfaceManager: { getFieldInterface: vi.fn((name) => ({ name })) } };
61
+ const second = { collectionFieldInterfaceManager: { getFieldInterface: vi.fn((name) => ({ name })) } };
62
+
63
+ expect(getCollectionFieldInterface('input', {}, first, second)).toEqual({ name: 'input' });
64
+ expect(first.collectionFieldInterfaceManager.getFieldInterface).toHaveBeenCalledWith('input');
65
+ expect(second.collectionFieldInterfaceManager.getFieldInterface).not.toHaveBeenCalled();
66
+ expect(getCollectionFieldInterface(undefined, first)).toBeUndefined();
67
+ expect(getCollectionFieldInterface('input', {})).toBeUndefined();
68
+ });
69
+
70
+ it('uses collection field interface resolver from getInterfaceOptions', () => {
71
+ const { ds, m } = setup();
72
+ const ctx = m.flowEngine.context;
73
+ const getOwnerFieldInterface = vi.fn((name: string) => ({ name, source: 'owner' }));
74
+ const getLegacyFieldInterface = vi.fn((name: string) => ({ name, source: 'legacy' }));
75
+
76
+ ctx.defineProperty('app', {
77
+ value: {
78
+ dataSourceManager: {
79
+ collectionFieldInterfaceManager: {
80
+ getFieldInterface: getLegacyFieldInterface,
81
+ },
82
+ },
83
+ },
84
+ });
85
+ ds.addCollection({
86
+ name: 'posts',
87
+ fields: [{ name: 'title', type: 'string', interface: 'input' }],
88
+ });
89
+
90
+ const field = ds.getCollection('posts')!.getField('title')!;
91
+ expect(field.getInterfaceOptions()).toEqual({ name: 'input', source: 'legacy' });
92
+
93
+ m.setCollectionFieldInterfaceManager({ getFieldInterface: getOwnerFieldInterface });
94
+ expect(field.getInterfaceOptions()).toEqual({ name: 'input', source: 'owner' });
95
+ expect(getLegacyFieldInterface).toHaveBeenCalledTimes(1);
96
+ });
58
97
  });