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

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 (83) hide show
  1. package/lib/acl/Acl.d.ts +2 -1
  2. package/lib/acl/Acl.js +28 -0
  3. package/lib/components/FlowContextSelector.js +62 -13
  4. package/lib/components/FormItem.js +11 -7
  5. package/lib/components/MobilePopup.js +39 -10
  6. package/lib/components/MobilePopup.style.js +11 -1
  7. package/lib/components/subModel/LazyDropdown.js +62 -33
  8. package/lib/components/variables/VariableHybridInput.d.ts +9 -0
  9. package/lib/components/variables/VariableHybridInput.js +146 -17
  10. package/lib/components/variables/VariableInput.js +19 -7
  11. package/lib/components/variables/VariableTag.js +48 -36
  12. package/lib/components/variables/types.d.ts +21 -0
  13. package/lib/flowContext.d.ts +12 -1
  14. package/lib/flowContext.js +47 -6
  15. package/lib/flowEngine.js +6 -0
  16. package/lib/flowI18n.js +3 -3
  17. package/lib/locale/en-US.json +2 -0
  18. package/lib/locale/index.d.ts +4 -0
  19. package/lib/locale/zh-CN.json +2 -0
  20. package/lib/resources/flowResource.js +1 -0
  21. package/lib/types.d.ts +3 -1
  22. package/lib/types.js +1 -0
  23. package/lib/utils/associationObjectVariable.d.ts +10 -0
  24. package/lib/utils/associationObjectVariable.js +10 -7
  25. package/lib/utils/dateVariable.d.ts +22 -0
  26. package/lib/utils/dateVariable.js +123 -16
  27. package/lib/utils/dirtyAwareApiClient.d.ts +1 -0
  28. package/lib/utils/dirtyAwareApiClient.js +280 -13
  29. package/lib/utils/index.d.ts +3 -3
  30. package/lib/utils/index.js +8 -0
  31. package/lib/utils/loadedPageCache.d.ts +1 -0
  32. package/lib/utils/loadedPageCache.js +6 -0
  33. package/lib/utils/params-resolvers.d.ts +3 -0
  34. package/lib/utils/params-resolvers.js +10 -0
  35. package/lib/utils/variablesParams.js +5 -0
  36. package/lib/views/createViewMeta.d.ts +1 -0
  37. package/lib/views/createViewMeta.js +53 -22
  38. package/package.json +4 -4
  39. package/src/__tests__/createViewMeta.popup.test.ts +84 -1
  40. package/src/__tests__/flowContext.test.ts +8 -0
  41. package/src/__tests__/flowI18n.test.ts +11 -0
  42. package/src/__tests__/objectVariable.test.ts +6 -1
  43. package/src/__tests__/runjsFormSubmit.test.ts +138 -0
  44. package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
  45. package/src/acl/Acl.tsx +36 -1
  46. package/src/acl/__tests__/Acl.test.tsx +70 -0
  47. package/src/components/FlowContextSelector.tsx +73 -12
  48. package/src/components/FormItem.tsx +12 -7
  49. package/src/components/MobilePopup.style.ts +12 -1
  50. package/src/components/MobilePopup.tsx +42 -10
  51. package/src/components/__tests__/FormItem.test.tsx +17 -2
  52. package/src/components/__tests__/MobilePopup.test.tsx +150 -0
  53. package/src/components/subModel/LazyDropdown.tsx +71 -38
  54. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
  55. package/src/components/subModel/__tests__/LazyDropdown.test.tsx +202 -0
  56. package/src/components/variables/VariableHybridInput.tsx +185 -14
  57. package/src/components/variables/VariableInput.tsx +32 -7
  58. package/src/components/variables/VariableTag.tsx +51 -37
  59. package/src/components/variables/__tests__/FlowContextSelector.test.tsx +95 -3
  60. package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
  61. package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
  62. package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
  63. package/src/components/variables/types.ts +21 -0
  64. package/src/flowContext.ts +79 -6
  65. package/src/flowEngine.ts +6 -0
  66. package/src/flowI18n.ts +8 -3
  67. package/src/locale/__tests__/index.test.ts +21 -0
  68. package/src/locale/en-US.json +2 -0
  69. package/src/locale/zh-CN.json +2 -0
  70. package/src/resources/__tests__/flowResource.test.ts +3 -0
  71. package/src/resources/flowResource.ts +1 -0
  72. package/src/types.ts +2 -0
  73. package/src/utils/__tests__/dateVariable.test.ts +57 -4
  74. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +321 -0
  75. package/src/utils/__tests__/variablesParams.test.ts +28 -1
  76. package/src/utils/associationObjectVariable.ts +9 -6
  77. package/src/utils/dateVariable.ts +145 -18
  78. package/src/utils/dirtyAwareApiClient.ts +348 -13
  79. package/src/utils/index.ts +17 -2
  80. package/src/utils/loadedPageCache.ts +7 -0
  81. package/src/utils/params-resolvers.ts +12 -0
  82. package/src/utils/variablesParams.ts +10 -0
  83. package/src/views/createViewMeta.ts +52 -18
@@ -9,12 +9,17 @@
9
9
 
10
10
  import { css, cx } from '@emotion/css';
11
11
  import { Space, theme } from 'antd';
12
- import React, { isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
+ import { FormItemInputContext } from 'antd/es/form/context';
13
+ import React, { isValidElement, useContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
14
  import type { MetaTreeNode } from '../../flowContext';
14
15
  import { useFlowContext } from '../../FlowContextProvider';
15
16
  import { FlowContextSelector } from '../FlowContextSelector';
16
17
  import { useResolvedMetaTree } from './useResolvedMetaTree';
17
- import { formatPathToValue as defaultFormatPathToValue, parseValueToPath as defaultParseValueToPath } from './utils';
18
+ import {
19
+ formatPathToValue as defaultFormatPathToValue,
20
+ loadMetaTreeChildren,
21
+ parseValueToPath as defaultParseValueToPath,
22
+ } from './utils';
18
23
 
19
24
  type RangeIndexes = [number, number, number, number];
20
25
 
@@ -31,12 +36,21 @@ export interface VariableHybridInputProps {
31
36
  value?: string;
32
37
  onChange?: (value: string) => void;
33
38
  disabled?: boolean;
39
+ readOnly?: boolean;
34
40
  placeholder?: string;
35
41
  addonBefore?: React.ReactNode;
36
42
  metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
37
43
  converters?: VariableHybridInputConverters;
38
44
  style?: React.CSSProperties;
39
45
  className?: string;
46
+ /**
47
+ * Validation status — turns the input border red (`error`) or amber
48
+ * (`warning`). Usually omitted: when rendered inside an antd `Form.Item`, the
49
+ * status is read automatically from `FormItemInputContext`, so dropping this
50
+ * into a `Form.Item` with failing rules colours the border with no extra
51
+ * wiring. An explicit prop wins over the inherited form status.
52
+ */
53
+ status?: 'error' | 'warning';
40
54
  }
41
55
 
42
56
  function reactNodeToPlainText(node: React.ReactNode): string {
@@ -83,14 +97,38 @@ function normalizeVariableKey(value: string): string {
83
97
  .trim();
84
98
  }
85
99
 
86
- function renderHTML(value: string, labelMap: Map<string, string>, regExp: RegExp) {
100
+ function renderHTML(value: string, regExp: RegExp, resolveLabel: (matched: string) => string | undefined) {
87
101
  const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
88
102
  return escapeHtml(value || '').replace(re, (matched) => {
89
- const label = labelMap.get(normalizeVariableKey(matched)) || matched;
103
+ const label = resolveLabel(matched) || matched;
90
104
  return createTagHTML(matched, label);
91
105
  });
92
106
  }
93
107
 
108
+ // Resolve a `{{ … }}` reference path to its slash-joined title chain by walking the (possibly lazily-expanded) meta tree
109
+ // live. Unlike `buildLabelMap` — which pre-walks only already-loaded (array) children into a memoized map — this reads
110
+ // the tree's CURRENT contents at call time, so a level expanded in place (by the cascader's loadData when the user
111
+ // drills in, or by the preload effect) is reflected on the very next render without the memoized map having to rebuild.
112
+ // This is what fixes a just-picked deep node rendering as its raw `{{ … }}` token. Returns undefined if any segment is
113
+ // missing or sits below a still-unresolved (thunk) level — the caller then falls back to the raw token.
114
+ function resolveTitlesByPath(
115
+ roots: MetaTreeNode[] | undefined,
116
+ path: string[] | undefined,
117
+ ctxT: (text: string) => string,
118
+ ): string | undefined {
119
+ if (!roots || !path || !path.length) return undefined;
120
+ const titles: string[] = [];
121
+ let nodes: MetaTreeNode[] | undefined = roots;
122
+ for (const segment of path) {
123
+ if (!nodes) return undefined;
124
+ const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
125
+ if (!matched) return undefined;
126
+ titles.push(reactNodeToPlainText(matched.title || matched.name));
127
+ nodes = Array.isArray(matched.children) ? (matched.children as MetaTreeNode[]) : undefined;
128
+ }
129
+ return titles.map(ctxT).join('/');
130
+ }
131
+
94
132
  function buildLabelMap(
95
133
  nodes: MetaTreeNode[] | undefined,
96
134
  ctxT: (text: string) => string,
@@ -116,6 +154,42 @@ function buildLabelMap(
116
154
  return map;
117
155
  }
118
156
 
157
+ // Collect every variable reference path in `value` (one per `{{ … }}` token). Used to preload lazy meta-tree levels so
158
+ // a saved reference whose label lives below an unexpanded (thunk) level still resolves to a readable tag.
159
+ function collectReferencePaths(
160
+ value: string,
161
+ regExp: RegExp,
162
+ parseValueToPath: (value?: string) => string[] | undefined,
163
+ ): string[][] {
164
+ const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
165
+ const paths: string[][] = [];
166
+ for (const matched of value.match(re) ?? []) {
167
+ const path = parseValueToPath(matched);
168
+ if (path && path.length) {
169
+ paths.push(path);
170
+ }
171
+ }
172
+ return paths;
173
+ }
174
+
175
+ // Walk one reference path down the meta tree, resolving each lazy `children` thunk in place. Returns true if it
176
+ // resolved at least one level (so the caller knows to recompute the label map). Mirrors `TypedVariableInput`'s preload.
177
+ async function preloadReferencePath(path: string[], roots: MetaTreeNode[]): Promise<boolean> {
178
+ let nodes: MetaTreeNode[] | undefined = roots;
179
+ let didLoad = false;
180
+ for (const segment of path) {
181
+ if (!nodes) break;
182
+ const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
183
+ if (!matched) break;
184
+ if (typeof matched.children === 'function') {
185
+ matched.children = await loadMetaTreeChildren(matched);
186
+ didLoad = true;
187
+ }
188
+ nodes = Array.isArray(matched.children) ? matched.children : undefined;
189
+ }
190
+ return didLoad;
191
+ }
192
+
119
193
  function pasteHTML(container: HTMLElement, html: string, indexes?: RangeIndexes) {
120
194
  const selection = window.getSelection?.();
121
195
  const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
@@ -222,10 +296,14 @@ function getCurrentRange(element: HTMLElement): RangeIndexes {
222
296
  }
223
297
 
224
298
  const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props) => {
225
- const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, style } = props;
299
+ const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, readOnly, style } = props;
226
300
  const { token } = theme.useToken();
227
301
  const ctx = useFlowContext();
228
302
  const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
303
+ // Inherit the antd Form.Item validation status (red/amber border) unless an explicit `status` prop overrides it — so
304
+ // the border colours automatically inside a failing `Form.Item`, no extra wiring for callers.
305
+ const formItemStatus = useContext(FormItemInputContext)?.status;
306
+ const effectiveStatus = props.status ?? formItemStatus;
229
307
  const inputRef = useRef<HTMLDivElement>(null);
230
308
  const [isComposing, setIsComposing] = useState(false);
231
309
  const [changed, setChanged] = useState(false);
@@ -233,13 +311,63 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
233
311
 
234
312
  const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : String(props.value);
235
313
  const variableRegExp = converters?.variableRegExp ?? DEFAULT_VARIABLE_REGEXP;
314
+ const parseValueToPath = converters?.parseValueToPath ?? defaultParseValueToPath;
315
+
316
+ // Bumped after a saved reference's lazy meta-tree levels are resolved, so the label map (below) recomputes once the
317
+ // deep titles are actually loaded.
318
+ const [loadedFlag, setLoadedFlag] = useState(0);
319
+
320
+ // Preload the lazy levels every `{{ … }}` reference in `value` points through. `buildLabelMap` only walks
321
+ // already-loaded (array) children, so without this a reference below an unexpanded thunk level (e.g. a workflow
322
+ // node's output fields under `$jobsMapByNodeKey.<nodeKey>`) renders as the raw `{{ … }}` text instead of its
323
+ // node/field labels. Mirrors `TypedVariableInput`'s preload.
324
+ useEffect(() => {
325
+ if (!value || !Array.isArray(resolvedMetaTree) || !resolvedMetaTree.length) {
326
+ return;
327
+ }
328
+ const paths = collectReferencePaths(value, variableRegExp, parseValueToPath);
329
+ if (!paths.length) {
330
+ return;
331
+ }
332
+ let cancelled = false;
333
+ const run = async () => {
334
+ let didLoad = false;
335
+ for (const path of paths) {
336
+ const loaded = await preloadReferencePath(path, resolvedMetaTree as MetaTreeNode[]);
337
+ if (cancelled) return;
338
+ didLoad = didLoad || loaded;
339
+ }
340
+ if (didLoad && !cancelled) {
341
+ setLoadedFlag((prev) => prev + 1);
342
+ }
343
+ };
344
+ run();
345
+ return () => {
346
+ cancelled = true;
347
+ };
348
+ }, [value, resolvedMetaTree, variableRegExp, parseValueToPath]);
236
349
 
237
350
  const labelMap = useMemo(
238
351
  () => buildLabelMap(resolvedMetaTree as MetaTreeNode[] | undefined, ctx.t, converters),
239
- [resolvedMetaTree, ctx, converters],
352
+ // `loadedFlag` is read so the map recomputes after a lazy level resolves.
353
+ // eslint-disable-next-line react-hooks/exhaustive-deps
354
+ [resolvedMetaTree, ctx, converters, loadedFlag],
355
+ );
356
+
357
+ // Resolve one `{{ … }}` token to its label: the pre-built map first (cheap, covers statically-loaded levels), then a LIVE walk of the current meta tree. The live fallback is what makes a just-picked deep reference render its label immediately — when the user drills into a lazy level, the cascader resolves that level onto the meta tree in place WITHOUT changing the tree reference or bumping `loadedFlag`, so the memoized `labelMap` still misses it; walking the tree's current contents finds the freshly-loaded titles on the same render.
358
+ const resolveLabel = useCallback(
359
+ (matched: string): string | undefined => {
360
+ const mapped = labelMap.get(normalizeVariableKey(matched));
361
+ if (mapped) return mapped;
362
+ const path = parseValueToPath(matched);
363
+ return resolveTitlesByPath(resolvedMetaTree as MetaTreeNode[] | undefined, path, ctx.t);
364
+ },
365
+ // `loadedFlag` is read so a resolved lazy level re-creates this callback and re-renders the tags. `ctx` carries the translation fn.
366
+ // eslint-disable-next-line react-hooks/exhaustive-deps
367
+ [labelMap, parseValueToPath, resolvedMetaTree, ctx, loadedFlag],
240
368
  );
241
369
 
242
- const [html, setHtml] = useState(() => renderHTML(value, labelMap, variableRegExp));
370
+ const [html, setHtml] = useState(() => renderHTML(value, variableRegExp, resolveLabel));
243
371
 
244
372
  const emitChange = useCallback(
245
373
  (target: HTMLElement) => {
@@ -249,12 +377,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
249
377
  );
250
378
 
251
379
  useEffect(() => {
252
- setHtml(renderHTML(value, labelMap, variableRegExp));
380
+ setHtml(renderHTML(value, variableRegExp, resolveLabel));
253
381
  if (!changed) {
254
382
  setRange([-1, 0, -1, 0]);
255
383
  }
256
384
  // eslint-disable-next-line react-hooks/exhaustive-deps
257
- }, [value, labelMap]);
385
+ }, [value, resolveLabel]);
258
386
 
259
387
  // Restore caret position after html update
260
388
  useEffect(() => {
@@ -331,12 +459,13 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
331
459
 
332
460
  const handleInput = useCallback(
333
461
  ({ currentTarget }: React.FormEvent<HTMLDivElement>) => {
462
+ if (readOnly) return;
334
463
  if (isComposing) return;
335
464
  setChanged(true);
336
465
  setRange(getCurrentRange(currentTarget));
337
466
  emitChange(currentTarget);
338
467
  },
339
- [emitChange, isComposing],
468
+ [emitChange, isComposing, readOnly],
340
469
  );
341
470
 
342
471
  const handleBlur = useCallback(({ currentTarget }: React.FocusEvent<HTMLDivElement>) => {
@@ -352,6 +481,7 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
352
481
  const handlePaste = useCallback(
353
482
  (event: React.ClipboardEvent<HTMLDivElement>) => {
354
483
  event.preventDefault();
484
+ if (readOnly) return;
355
485
  // Paste as plain text only; variable tags must be inserted via the picker.
356
486
  const text = event.clipboardData.getData('text/plain').replace(/\n/g, ' ');
357
487
  if (!text) return;
@@ -360,18 +490,19 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
360
490
  setRange(getCurrentRange(event.currentTarget));
361
491
  emitChange(event.currentTarget);
362
492
  },
363
- [emitChange],
493
+ [emitChange, readOnly],
364
494
  );
365
495
 
366
496
  const handleCompositionStart = useCallback(() => setIsComposing(true), []);
367
497
  const handleCompositionEnd = useCallback(
368
498
  ({ currentTarget }: React.CompositionEvent<HTMLDivElement>) => {
369
499
  setIsComposing(false);
500
+ if (readOnly) return;
370
501
  setChanged(true);
371
502
  setRange(getCurrentRange(currentTarget));
372
503
  emitChange(currentTarget);
373
504
  },
374
- [emitChange],
505
+ [emitChange, readOnly],
375
506
  );
376
507
 
377
508
  const wrapperClassName = useMemo(
@@ -492,6 +623,42 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
492
623
  border-color: ${token.colorBorder};
493
624
  }
494
625
  }
626
+
627
+ &.is-readonly {
628
+ cursor: default;
629
+
630
+ &:hover {
631
+ border-color: ${token.colorBorder};
632
+ }
633
+ }
634
+
635
+ &.is-error {
636
+ border-color: ${token.colorError};
637
+
638
+ &:hover {
639
+ border-color: ${token.colorErrorBorderHover};
640
+ }
641
+
642
+ &:focus,
643
+ &:focus-visible {
644
+ border-color: ${token.colorError};
645
+ box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.colorErrorOutline};
646
+ }
647
+ }
648
+
649
+ &.is-warning {
650
+ border-color: ${token.colorWarning};
651
+
652
+ &:hover {
653
+ border-color: ${token.colorWarningBorderHover};
654
+ }
655
+
656
+ &:focus,
657
+ &:focus-visible {
658
+ border-color: ${token.colorWarning};
659
+ box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.colorWarningOutline};
660
+ }
661
+ }
495
662
  `;
496
663
  }, [token, addonBefore]);
497
664
 
@@ -505,8 +672,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
505
672
  aria-label="textbox"
506
673
  className={cx(editorClassName, {
507
674
  'is-disabled': disabled,
675
+ 'is-readonly': readOnly,
676
+ 'is-error': effectiveStatus === 'error',
677
+ 'is-warning': effectiveStatus === 'warning',
508
678
  })}
509
- contentEditable={!disabled}
679
+ contentEditable={!disabled && !readOnly}
680
+ aria-readonly={readOnly}
510
681
  data-placeholder={placeholder}
511
682
  onInput={handleInput}
512
683
  onBlur={handleBlur}
@@ -519,7 +690,7 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
519
690
  <FlowContextSelector
520
691
  metaTree={metaTree}
521
692
  disabled={disabled}
522
- parseValueToPath={converters?.parseValueToPath ?? defaultParseValueToPath}
693
+ parseValueToPath={parseValueToPath}
523
694
  formatPathToValue={(item) => converters?.formatPathToValue?.(item) || defaultFormatPathToValue(item)}
524
695
  onChange={handleSelectorChange}
525
696
  />
@@ -125,16 +125,19 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
125
125
  [onChange],
126
126
  );
127
127
 
128
+ const resolvedPath = useMemo(() => {
129
+ return resolvePathFromValue?.(innerValue);
130
+ }, [innerValue, resolvePathFromValue]);
131
+
128
132
  const resolvedMetaTreeNode = useMemo(() => {
129
133
  if (currentMetaTreeNode) return currentMetaTreeNode;
130
134
  if (Array.isArray(resolvedMetaTree)) {
131
- const path = resolvePathFromValue?.(innerValue);
132
- if (path) {
133
- return findMetaTreeNodeByPath(resolvedMetaTree, path);
135
+ if (resolvedPath) {
136
+ return findMetaTreeNodeByPath(resolvedMetaTree, resolvedPath);
134
137
  }
135
138
  }
136
139
  return null;
137
- }, [currentMetaTreeNode, innerValue, resolvedMetaTree, resolvePathFromValue]);
140
+ }, [currentMetaTreeNode, resolvedMetaTree, resolvedPath]);
138
141
 
139
142
  // 当 value 存在但 currentMetaTreeNode 还未恢复,尝试按路径逐级加载(支持 children 为函数的场景)
140
143
  useEffect(() => {
@@ -192,14 +195,30 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
192
195
  };
193
196
 
194
197
  restoreFromValue();
195
- }, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode]);
198
+ }, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode, value]);
196
199
 
197
200
  const ValueComponent = useMemo(() => {
198
201
  const Component = renderInputComponent?.(resolvedMetaTreeNode);
199
202
  const CustomComponent = resolvedMetaTreeNode?.render;
200
- const finalComponent = isVariableValue(innerValue) ? VariableTag : Component || CustomComponent || Input;
203
+ // Some domains (workflow) persist variables as `{{$context...}}` rather than the core `{{ ctx... }}` form.
204
+ // When a custom converter recognizes such a value as a variable path, render VariableTag even if the path no longer
205
+ // exists in the current meta tree. VariableTag will then show either the resolved label or the parsing-failed state.
206
+ const shouldRenderVariableTag =
207
+ isVariableValue(innerValue) ||
208
+ (Array.isArray(resolvedPath) && resolvedPath.length > 0 && !Component && !CustomComponent);
209
+ const finalComponent = shouldRenderVariableTag ? VariableTag : Component || CustomComponent || Input;
201
210
  return finalComponent;
202
- }, [renderInputComponent, resolvedMetaTreeNode, innerValue]);
211
+ }, [renderInputComponent, resolvedMetaTreeNode, innerValue, resolvedPath]);
212
+
213
+ const isVariableActive = useMemo(() => {
214
+ return (
215
+ isVariableValue(innerValue) ||
216
+ (Array.isArray(resolvedPath) &&
217
+ resolvedPath.length > 0 &&
218
+ !renderInputComponent?.(resolvedMetaTreeNode) &&
219
+ !resolvedMetaTreeNode?.render)
220
+ );
221
+ }, [innerValue, renderInputComponent, resolvedMetaTreeNode, resolvedPath]);
203
222
 
204
223
  useEffect(() => {
205
224
  if (!resolvedMetaTreeNode) return;
@@ -303,8 +322,11 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
303
322
  return {
304
323
  ...baseProps,
305
324
  onClear: handleClear,
325
+ disabled,
326
+ allowCustomTagInput: false,
306
327
  metaTreeNode: resolvedMetaTreeNode,
307
328
  metaTree,
329
+ resolvedPath,
308
330
  style: stableProps.style,
309
331
  };
310
332
  }
@@ -327,6 +349,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
327
349
  disabled,
328
350
  handleClear,
329
351
  resolvedMetaTreeNode,
352
+ resolvedPath,
330
353
  metaTree,
331
354
  ValueComponent,
332
355
  stableProps,
@@ -356,6 +379,8 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
356
379
  <FlowContextSelector
357
380
  metaTree={resolvedMetaTree}
358
381
  value={innerValue}
382
+ active={isVariableActive}
383
+ disabled={disabled}
359
384
  onChange={handleVariableSelect}
360
385
  parseValueToPath={resolvePathFromValue}
361
386
  formatPathToValue={resolveValueFromPath}
@@ -17,41 +17,46 @@ import { useRequest } from 'ahooks';
17
17
  import { useFlowContext } from '../../FlowContextProvider';
18
18
  import type { MetaTreeNode } from '../../flowContext';
19
19
 
20
+ const VARIABLE_PARSING_FAILED_TEXT = 'Variable parsing failed';
21
+
20
22
  const VariableTagComponent: React.FC<VariableTagProps> = ({
21
23
  value,
22
24
  onClear,
25
+ disabled = false,
26
+ allowCustomTagInput = true,
23
27
  className,
24
28
  style,
25
29
  metaTreeNode,
26
30
  metaTree,
31
+ resolvedPath,
27
32
  }) => {
28
33
  const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
29
34
  const ctx = useFlowContext();
30
35
 
31
- const { data: displayedValue } = useRequest(
36
+ const { data: displayState } = useRequest(
32
37
  async () => {
33
- const resolveLabelFromPath = async (rawPath?: (string | number)[]): Promise<string | null> => {
34
- if (!rawPath) return null;
35
- if (!Array.isArray(rawPath)) return null;
36
- if (!Array.isArray(resolvedMetaTree)) return null;
38
+ const resolveLabelFromPath = async (
39
+ rawPath?: (string | number)[],
40
+ ): Promise<{ label: string | null; resolved: boolean; attempted: boolean }> => {
41
+ if (!rawPath) return { label: null, resolved: false, attempted: false };
42
+ if (!Array.isArray(rawPath)) return { label: null, resolved: false, attempted: false };
43
+ if (!Array.isArray(resolvedMetaTree)) return { label: null, resolved: false, attempted: false };
37
44
 
38
45
  // 兼容 metaTree 为子树:顶层不含首段时,裁剪首段
39
46
  const topNames = new Set((resolvedMetaTree || []).map((n: any) => String(n?.name)));
40
47
  const path = !topNames.has(String(rawPath[0])) ? rawPath.slice(1) : rawPath;
41
- if (!path.length) return '';
48
+ if (!path.length) return { label: '', resolved: true, attempted: true };
42
49
 
43
50
  let nodes: MetaTreeNode[] | undefined = resolvedMetaTree as MetaTreeNode[];
44
51
  const titleChain: string[] = [];
45
- let matchedCount = 0;
46
52
 
47
53
  for (let i = 0; i < path.length; i++) {
48
- if (!nodes) break;
54
+ if (!nodes) return { label: null, resolved: false, attempted: true };
49
55
  const seg = String(path[i]);
50
56
  const node = nodes.find((n) => String(n?.name) === seg) as MetaTreeNode | undefined;
51
- if (!node) break; // 停在第一个无效段之前
57
+ if (!node) return { label: null, resolved: false, attempted: true };
52
58
 
53
59
  titleChain.push(String(node.title ?? node.name ?? seg));
54
- matchedCount = i + 1;
55
60
 
56
61
  if (i < path.length - 1) {
57
62
  if (Array.isArray(node.children)) {
@@ -70,36 +75,43 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
70
75
  }
71
76
  }
72
77
 
73
- if (matchedCount === 0) return null;
74
-
75
- let label = titleChain.map(ctx.t).join('/');
76
- if (matchedCount < path.length) {
77
- const tail = path.slice(matchedCount).join('/');
78
- label = tail ? `${label}/${tail}` : label;
79
- }
80
- return label;
78
+ return { label: titleChain.map(ctx.t).join('/'), resolved: true, attempted: true };
81
79
  };
82
80
 
83
81
  // 1) 优先使用已解析到的节点(包含完整父标题链)
84
82
  if (metaTreeNode?.parentTitles) {
85
- return [...metaTreeNode.parentTitles, metaTreeNode.title].map(ctx.t).join('/');
83
+ return {
84
+ text: [...metaTreeNode.parentTitles, metaTreeNode.title].map(ctx.t).join('/'),
85
+ invalid: false,
86
+ };
86
87
  }
87
88
 
88
89
  // 2) metaTreeNode 存在但缺少 parentTitles:尝试根据 value/metaTreeNode.paths 从 metaTree 还原完整路径
89
90
  if (metaTreeNode) {
90
- const rawPath = parseValueToPath(value) || metaTreeNode.paths;
91
- const label = await resolveLabelFromPath(rawPath as any);
92
- return label ?? ctx.t(metaTreeNode.title) ?? '';
91
+ const rawPath = resolvedPath || parseValueToPath(value) || metaTreeNode.paths;
92
+ const result = await resolveLabelFromPath(rawPath as any);
93
+ if (result.resolved && result.label != null) {
94
+ return { text: result.label, invalid: false };
95
+ }
96
+ if (result.attempted) {
97
+ return { text: VARIABLE_PARSING_FAILED_TEXT, invalid: true, rawValue: String(value ?? '') };
98
+ }
99
+ return { text: ctx.t(metaTreeNode.title) ?? '', invalid: false };
93
100
  }
94
101
 
95
102
  // 3) 无 metaTreeNode:从 value 还原路径并拼接标题链;若找不到任何前缀则回退原始路径字符串
96
- if (!value) return String(value);
97
- const rawPath = parseValueToPath(value);
98
- const label = await resolveLabelFromPath(rawPath as any);
99
- if (label != null) return label;
100
- return Array.isArray(rawPath) ? rawPath.join('/') : String(value);
103
+ if (!value) return { text: String(value), invalid: false };
104
+ const rawPath = resolvedPath || parseValueToPath(value);
105
+ const result = await resolveLabelFromPath(rawPath as any);
106
+ if (result.resolved && result.label != null) {
107
+ return { text: result.label, invalid: false };
108
+ }
109
+ if (result.attempted) {
110
+ return { text: VARIABLE_PARSING_FAILED_TEXT, invalid: true, rawValue: String(value ?? '') };
111
+ }
112
+ return { text: Array.isArray(rawPath) ? rawPath.join('/') : String(value), invalid: false };
101
113
  },
102
- { refreshDeps: [resolvedMetaTree, value, metaTreeNode] },
114
+ { refreshDeps: [resolvedMetaTree, resolvedPath, value, metaTreeNode] },
103
115
  );
104
116
 
105
117
  const { token } = theme.useToken();
@@ -119,13 +131,13 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
119
131
  `;
120
132
 
121
133
  const customTagRender = (props: any) => {
122
- const { label } = props;
123
- const fullText = typeof label === 'string' ? label : String(label);
134
+ const fullText = displayState?.text || (typeof props.label === 'string' ? props.label : String(props.label));
135
+ const tooltipText = displayState?.invalid ? displayState.rawValue || fullText : fullText;
124
136
 
125
137
  return (
126
- <Tooltip title={fullText} placement="top" getPopupContainer={() => document.body}>
138
+ <Tooltip title={tooltipText} placement="top" getPopupContainer={() => document.body}>
127
139
  <Tag
128
- color="blue"
140
+ color={displayState?.invalid ? 'error' : 'blue'}
129
141
  style={{
130
142
  margin: `0 ${token.marginXXS || token.marginXS}px`,
131
143
  borderRadius: token.borderRadiusSM,
@@ -166,12 +178,14 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
166
178
  flex: '1 1 auto',
167
179
  ...style,
168
180
  }}
169
- value={displayedValue ? [displayedValue] : []}
170
- mode="tags"
181
+ value={displayState?.text ? [displayState.text] : []}
182
+ mode={allowCustomTagInput ? 'tags' : 'multiple'}
171
183
  open={false}
172
- allowClear={!!onClear}
173
- onClear={onClear}
174
- disabled={!onClear}
184
+ showSearch={allowCustomTagInput}
185
+ searchValue={allowCustomTagInput ? undefined : ''}
186
+ allowClear={!disabled && !!onClear}
187
+ onClear={disabled ? undefined : onClear}
188
+ disabled={disabled || !onClear}
175
189
  variant="outlined"
176
190
  suffixIcon={null}
177
191
  tagRender={customTagRender}