@nocobase/flow-engine 2.2.0-alpha.7 → 2.2.0-beta.10

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.
@@ -40,18 +40,6 @@ type SelectedPathInfo = {
40
40
  meta?: ContextSelectorItem['meta'];
41
41
  };
42
42
 
43
- type MetaNodeTooltipOptions = { tooltip?: React.ReactNode };
44
-
45
- function getMetaNodeTooltip(meta?: ContextSelectorItem['meta']): React.ReactNode {
46
- if (!meta) {
47
- return undefined;
48
- }
49
-
50
- const metaWithTooltip = meta as ContextSelectorItem['meta'] & MetaNodeTooltipOptions;
51
- const options = meta.options as MetaNodeTooltipOptions | undefined;
52
- return metaWithTooltip.tooltip ?? options?.tooltip;
53
- }
54
-
55
43
  const normalizePath = (path: unknown): string[] | undefined => {
56
44
  if (!Array.isArray(path)) {
57
45
  return undefined;
@@ -97,7 +85,6 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
97
85
  value,
98
86
  onChange,
99
87
  children,
100
- active,
101
88
  metaTree,
102
89
  showSearch = false,
103
90
  parseValueToPath: customParseValueToPath = parseValueToPath,
@@ -105,7 +92,6 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
105
92
  open,
106
93
  onlyLeafSelectable = false,
107
94
  ignoreFieldNames,
108
- dropdownFooter,
109
95
  ...cascaderProps
110
96
  }) => {
111
97
  const { token } = theme.useToken();
@@ -133,28 +119,17 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
133
119
 
134
120
  // 文本国际化:仅当 label 为字符串时进行翻译
135
121
  const baseLabel = typeof o.label === 'string' ? flowCtx.t(o.label) : o.label;
136
- const labelText = typeof baseLabel === 'string' ? baseLabel : String(o.value);
137
- const tooltip = getMetaNodeTooltip(meta);
138
- const tooltipTitle = disabled
139
- ? disabledReason || tooltip || flowCtx.t('This variable is not available')
140
- : tooltip;
141
122
 
142
- const label = tooltipTitle ? (
123
+ const label = disabled ? (
143
124
  <span>
144
125
  {baseLabel}
145
126
  <Tooltip
146
- title={typeof tooltipTitle === 'string' ? flowCtx.t(tooltipTitle) : tooltipTitle}
147
- placement="top"
148
- classNames={{ root: 'flow-variable-tip' }}
127
+ title={disabledReason || flowCtx.t('This variable is not available')}
128
+ placement="right"
129
+ overlayClassName="flow-variable-disabled-tip"
149
130
  destroyTooltipOnHide
150
131
  >
151
- <QuestionCircleOutlined
152
- aria-label={`${labelText} tooltip`}
153
- style={{
154
- marginLeft: token.marginXXS,
155
- color: disabled ? token.colorTextDisabled : token.colorTextDescription,
156
- }}
157
- />
132
+ <QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.35)' }} />
158
133
  </Tooltip>
159
134
  </span>
160
135
  ) : (
@@ -169,7 +144,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
169
144
  };
170
145
  });
171
146
  },
172
- [flowCtx, token.colorTextDescription, token.colorTextDisabled, token.marginXXS],
147
+ [flowCtx],
173
148
  );
174
149
 
175
150
  // 用于强制重新渲染的状态
@@ -290,13 +265,13 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
290
265
 
291
266
  // 默认按钮组件
292
267
  const defaultChildren = useMemo(() => {
293
- const hasSelected = active ?? Boolean(currentPath && currentPath.length > 0);
268
+ const hasSelected = currentPath && currentPath.length > 0;
294
269
  return (
295
- <Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle} disabled={cascaderProps.disabled}>
270
+ <Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle}>
296
271
  x
297
272
  </Button>
298
273
  );
299
- }, [active, cascaderProps.disabled, currentPath]);
274
+ }, [currentPath]);
300
275
 
301
276
  // 处理选择变化事件
302
277
  const handleChange = useCallback(
@@ -385,41 +360,12 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
385
360
  [cascaderOnDropdownVisibleChange, open],
386
361
  );
387
362
 
388
- // Footer hint at the bottom of the dropdown. Defaults to the "double click to choose entire object" hint whenever
389
- // non-leaf selection is allowed (double-clicking a non-leaf selects the whole object). Callers can override with
390
- // their own node, or pass `null` to hide it.
391
- const footerNode = useMemo(() => {
392
- if (dropdownFooter !== undefined) {
393
- return dropdownFooter;
394
- }
395
- if (onlyLeafSelectable) {
396
- return null;
397
- }
398
- return (
399
- <div
400
- className={css`
401
- padding: 6px 12px;
402
- color: ${token.colorTextDescription};
403
- border-top: 1px solid ${token.colorSplit};
404
- font-size: ${token.fontSizeSM}px;
405
- `}
406
- >
407
- {flowCtx.t('Double click to choose entire object')}
408
- </div>
409
- );
410
- }, [dropdownFooter, onlyLeafSelectable, token, flowCtx]);
411
-
412
363
  const renderDropdown = useCallback(
413
364
  (menu: React.ReactElement) => {
414
365
  const cascaderMenuNode = cascaderDropdownRender ? cascaderDropdownRender(menu) : menu;
415
366
  const cascaderMenu = React.isValidElement(cascaderMenuNode) ? cascaderMenuNode : <>{cascaderMenuNode}</>;
416
367
  if (!isSearchEnabled || children === null) {
417
- return (
418
- <>
419
- {cascaderMenu}
420
- {footerNode}
421
- </>
422
- );
368
+ return cascaderMenu;
423
369
  }
424
370
 
425
371
  return (
@@ -435,11 +381,10 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
435
381
  />
436
382
  </div>
437
383
  {cascaderMenu}
438
- {footerNode}
439
384
  </>
440
385
  );
441
386
  },
442
- [cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText, footerNode],
387
+ [cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText],
443
388
  );
444
389
 
445
390
  const inlinePlaceholder =
@@ -54,20 +54,15 @@ const formItemPropKeys: (keyof ExtendedFormItemProps)[] = [
54
54
  'showLabel',
55
55
  ];
56
56
 
57
- const modelInternalPropKeys = ['globalSort'];
58
-
59
57
  export const FormItem = ({
60
58
  children,
61
59
  showLabel = true,
62
60
  labelWidth,
63
61
  ...rest
64
62
  }: ExtendedFormItemProps & ChildExtraProps) => {
65
- const forwardedRest = Object.fromEntries(
66
- Object.entries(rest).filter(([key]) => !modelInternalPropKeys.includes(key)),
67
- ) as ExtendedFormItemProps & ChildExtraProps;
68
63
  // 过滤掉 Form.Item 专用 props,只保留要传给子组件的
69
64
  const childProps = Object.fromEntries(
70
- Object.entries(forwardedRest).filter(([key]) => !formItemPropKeys.includes(key as keyof ExtendedFormItemProps)),
65
+ Object.entries(rest).filter(([key]) => !formItemPropKeys.includes(key as keyof ExtendedFormItemProps)),
71
66
  );
72
67
 
73
68
  const processedChildren =
@@ -79,7 +74,7 @@ export const FormItem = ({
79
74
  }
80
75
  return child;
81
76
  });
82
- const { label, labelWrap, colon = true, layout } = forwardedRest;
77
+ const { label, labelWrap, colon = true, layout } = rest;
83
78
  const effectiveLabelWrap = !layout || layout === 'vertical' ? true : labelWrap;
84
79
  const labelColStyle =
85
80
  layout === 'vertical' ? { width: labelWidth, ...verticalFormItemLabelStyle } : { width: labelWidth };
@@ -127,17 +122,17 @@ export const FormItem = ({
127
122
  };
128
123
  return (
129
124
  <Form.Item
130
- {...forwardedRest}
131
- style={{ ...formItemStyle, ...forwardedRest.style }}
125
+ {...rest}
126
+ style={{ ...formItemStyle, ...rest.style }}
132
127
  labelCol={{ style: labelColStyle }}
133
128
  layout={layout}
134
129
  label={renderLabel()}
135
130
  colon={false}
136
- extra={forwardedRest.extra && <span style={{ whiteSpace: 'pre-wrap' }}>{forwardedRest.extra}</span>}
131
+ extra={rest.extra && <span style={{ whiteSpace: 'pre-wrap' }}>{rest.extra}</span>}
137
132
  tooltip={
138
- forwardedRest.tooltip &&
133
+ rest.tooltip &&
139
134
  ({
140
- title: forwardedRest.tooltip,
135
+ title: rest.tooltip,
141
136
  overlayInnerStyle: { whiteSpace: 'pre-line' },
142
137
  } as TooltipProps)
143
138
  }
@@ -7,10 +7,8 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { render } from '@testing-library/react';
11
- import React from 'react';
12
- import { describe, expect, it, vi } from 'vitest';
13
- import { FormItem, formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
10
+ import { describe, expect, it } from 'vitest';
11
+ import { formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
14
12
 
15
13
  describe('FormItem', () => {
16
14
  it('keeps vertical label-to-value spacing consistent with v1', () => {
@@ -24,17 +22,4 @@ describe('FormItem', () => {
24
22
  marginBottom: 12,
25
23
  });
26
24
  });
27
-
28
- it('does not forward model-internal globalSort props to field children', () => {
29
- const Field = vi.fn(() => <input aria-label="field" />);
30
-
31
- render(
32
- <FormItem globalSort={['title']} placeholder="Title">
33
- <Field />
34
- </FormItem>,
35
- );
36
-
37
- expect(Field).toHaveBeenCalledWith(expect.not.objectContaining({ globalSort: expect.anything() }), {});
38
- expect(Field).toHaveBeenCalledWith(expect.objectContaining({ placeholder: 'Title' }), {});
39
- });
40
25
  });
@@ -9,17 +9,12 @@
9
9
 
10
10
  import { css, cx } from '@emotion/css';
11
11
  import { Space, theme } from 'antd';
12
- import { FormItemInputContext } from 'antd/es/form/context';
13
- import React, { isValidElement, useContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
+ import React, { isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
13
  import type { MetaTreeNode } from '../../flowContext';
15
14
  import { useFlowContext } from '../../FlowContextProvider';
16
15
  import { FlowContextSelector } from '../FlowContextSelector';
17
16
  import { useResolvedMetaTree } from './useResolvedMetaTree';
18
- import {
19
- formatPathToValue as defaultFormatPathToValue,
20
- loadMetaTreeChildren,
21
- parseValueToPath as defaultParseValueToPath,
22
- } from './utils';
17
+ import { formatPathToValue as defaultFormatPathToValue, parseValueToPath as defaultParseValueToPath } from './utils';
23
18
 
24
19
  type RangeIndexes = [number, number, number, number];
25
20
 
@@ -36,21 +31,12 @@ export interface VariableHybridInputProps {
36
31
  value?: string;
37
32
  onChange?: (value: string) => void;
38
33
  disabled?: boolean;
39
- readOnly?: boolean;
40
34
  placeholder?: string;
41
35
  addonBefore?: React.ReactNode;
42
36
  metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
43
37
  converters?: VariableHybridInputConverters;
44
38
  style?: React.CSSProperties;
45
39
  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';
54
40
  }
55
41
 
56
42
  function reactNodeToPlainText(node: React.ReactNode): string {
@@ -97,38 +83,14 @@ function normalizeVariableKey(value: string): string {
97
83
  .trim();
98
84
  }
99
85
 
100
- function renderHTML(value: string, regExp: RegExp, resolveLabel: (matched: string) => string | undefined) {
86
+ function renderHTML(value: string, labelMap: Map<string, string>, regExp: RegExp) {
101
87
  const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
102
88
  return escapeHtml(value || '').replace(re, (matched) => {
103
- const label = resolveLabel(matched) || matched;
89
+ const label = labelMap.get(normalizeVariableKey(matched)) || matched;
104
90
  return createTagHTML(matched, label);
105
91
  });
106
92
  }
107
93
 
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
-
132
94
  function buildLabelMap(
133
95
  nodes: MetaTreeNode[] | undefined,
134
96
  ctxT: (text: string) => string,
@@ -154,42 +116,6 @@ function buildLabelMap(
154
116
  return map;
155
117
  }
156
118
 
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
-
193
119
  function pasteHTML(container: HTMLElement, html: string, indexes?: RangeIndexes) {
194
120
  const selection = window.getSelection?.();
195
121
  const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
@@ -296,14 +222,10 @@ function getCurrentRange(element: HTMLElement): RangeIndexes {
296
222
  }
297
223
 
298
224
  const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props) => {
299
- const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, readOnly, style } = props;
225
+ const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, style } = props;
300
226
  const { token } = theme.useToken();
301
227
  const ctx = useFlowContext();
302
228
  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;
307
229
  const inputRef = useRef<HTMLDivElement>(null);
308
230
  const [isComposing, setIsComposing] = useState(false);
309
231
  const [changed, setChanged] = useState(false);
@@ -311,63 +233,13 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
311
233
 
312
234
  const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : String(props.value);
313
235
  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]);
349
236
 
350
237
  const labelMap = useMemo(
351
238
  () => buildLabelMap(resolvedMetaTree as MetaTreeNode[] | undefined, ctx.t, 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],
239
+ [resolvedMetaTree, ctx, converters],
368
240
  );
369
241
 
370
- const [html, setHtml] = useState(() => renderHTML(value, variableRegExp, resolveLabel));
242
+ const [html, setHtml] = useState(() => renderHTML(value, labelMap, variableRegExp));
371
243
 
372
244
  const emitChange = useCallback(
373
245
  (target: HTMLElement) => {
@@ -377,12 +249,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
377
249
  );
378
250
 
379
251
  useEffect(() => {
380
- setHtml(renderHTML(value, variableRegExp, resolveLabel));
252
+ setHtml(renderHTML(value, labelMap, variableRegExp));
381
253
  if (!changed) {
382
254
  setRange([-1, 0, -1, 0]);
383
255
  }
384
256
  // eslint-disable-next-line react-hooks/exhaustive-deps
385
- }, [value, resolveLabel]);
257
+ }, [value, labelMap]);
386
258
 
387
259
  // Restore caret position after html update
388
260
  useEffect(() => {
@@ -459,13 +331,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
459
331
 
460
332
  const handleInput = useCallback(
461
333
  ({ currentTarget }: React.FormEvent<HTMLDivElement>) => {
462
- if (readOnly) return;
463
334
  if (isComposing) return;
464
335
  setChanged(true);
465
336
  setRange(getCurrentRange(currentTarget));
466
337
  emitChange(currentTarget);
467
338
  },
468
- [emitChange, isComposing, readOnly],
339
+ [emitChange, isComposing],
469
340
  );
470
341
 
471
342
  const handleBlur = useCallback(({ currentTarget }: React.FocusEvent<HTMLDivElement>) => {
@@ -481,7 +352,6 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
481
352
  const handlePaste = useCallback(
482
353
  (event: React.ClipboardEvent<HTMLDivElement>) => {
483
354
  event.preventDefault();
484
- if (readOnly) return;
485
355
  // Paste as plain text only; variable tags must be inserted via the picker.
486
356
  const text = event.clipboardData.getData('text/plain').replace(/\n/g, ' ');
487
357
  if (!text) return;
@@ -490,19 +360,18 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
490
360
  setRange(getCurrentRange(event.currentTarget));
491
361
  emitChange(event.currentTarget);
492
362
  },
493
- [emitChange, readOnly],
363
+ [emitChange],
494
364
  );
495
365
 
496
366
  const handleCompositionStart = useCallback(() => setIsComposing(true), []);
497
367
  const handleCompositionEnd = useCallback(
498
368
  ({ currentTarget }: React.CompositionEvent<HTMLDivElement>) => {
499
369
  setIsComposing(false);
500
- if (readOnly) return;
501
370
  setChanged(true);
502
371
  setRange(getCurrentRange(currentTarget));
503
372
  emitChange(currentTarget);
504
373
  },
505
- [emitChange, readOnly],
374
+ [emitChange],
506
375
  );
507
376
 
508
377
  const wrapperClassName = useMemo(
@@ -623,42 +492,6 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
623
492
  border-color: ${token.colorBorder};
624
493
  }
625
494
  }
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
- }
662
495
  `;
663
496
  }, [token, addonBefore]);
664
497
 
@@ -672,12 +505,8 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
672
505
  aria-label="textbox"
673
506
  className={cx(editorClassName, {
674
507
  'is-disabled': disabled,
675
- 'is-readonly': readOnly,
676
- 'is-error': effectiveStatus === 'error',
677
- 'is-warning': effectiveStatus === 'warning',
678
508
  })}
679
- contentEditable={!disabled && !readOnly}
680
- aria-readonly={readOnly}
509
+ contentEditable={!disabled}
681
510
  data-placeholder={placeholder}
682
511
  onInput={handleInput}
683
512
  onBlur={handleBlur}
@@ -690,7 +519,7 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
690
519
  <FlowContextSelector
691
520
  metaTree={metaTree}
692
521
  disabled={disabled}
693
- parseValueToPath={parseValueToPath}
522
+ parseValueToPath={converters?.parseValueToPath ?? defaultParseValueToPath}
694
523
  formatPathToValue={(item) => converters?.formatPathToValue?.(item) || defaultFormatPathToValue(item)}
695
524
  onChange={handleSelectorChange}
696
525
  />
@@ -192,7 +192,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
192
192
  };
193
193
 
194
194
  restoreFromValue();
195
- }, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode, value]);
195
+ }, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode]);
196
196
 
197
197
  const ValueComponent = useMemo(() => {
198
198
  const Component = renderInputComponent?.(resolvedMetaTreeNode);
@@ -356,8 +356,6 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
356
356
  <FlowContextSelector
357
357
  metaTree={resolvedMetaTree}
358
358
  value={innerValue}
359
- active={isVariableValue(innerValue)}
360
- disabled={disabled}
361
359
  onChange={handleVariableSelect}
362
360
  parseValueToPath={resolvePathFromValue}
363
361
  formatPathToValue={resolveValueFromPath}
@@ -67,23 +67,6 @@ describe('FlowContextSelector', () => {
67
67
  });
68
68
  });
69
69
 
70
- it('uses explicit active=false to keep the default trigger unhighlighted even when value parses to a path', async () => {
71
- const flowContext = createTestFlowContext();
72
-
73
- render(
74
- <TestFlowContextWrapper context={flowContext}>
75
- <FlowContextSelector
76
- metaTree={() => flowContext.getPropertyMetaTree()}
77
- value="{{ ctx.user.name }}"
78
- active={false}
79
- />
80
- </TestFlowContextWrapper>,
81
- );
82
-
83
- const button = await screen.findByRole('button');
84
- expect(button.className).not.toContain('ant-btn-primary');
85
- });
86
-
87
70
  it('should support function metaTree loading', async () => {
88
71
  const flowContext = createTestFlowContext();
89
72
  const metaTreeFn = vi.fn(() => flowContext.getPropertyMetaTree());
@@ -284,43 +267,6 @@ describe('FlowContextSelector', () => {
284
267
  });
285
268
  });
286
269
 
287
- it('shows enabled variable item tooltip above the menu item', async () => {
288
- const flowContext = createTestFlowContext();
289
- const metaTree: MetaTreeNode[] = [
290
- {
291
- name: '$system',
292
- title: 'System variables',
293
- type: '',
294
- paths: ['$system'],
295
- children: [
296
- {
297
- name: 'instanceId',
298
- title: 'Instance ID',
299
- type: '',
300
- paths: ['$system', 'instanceId'],
301
- options: { tooltip: 'The ID of current server instance' },
302
- },
303
- ],
304
- },
305
- ];
306
-
307
- render(
308
- <TestFlowContextWrapper context={flowContext}>
309
- <FlowContextSelector metaTree={metaTree} />
310
- </TestFlowContextWrapper>,
311
- );
312
-
313
- fireEvent.click(screen.getByRole('button'));
314
- fireEvent.click(await screen.findByText('System variables'));
315
-
316
- expect(await screen.findByText('Instance ID')).toBeInTheDocument();
317
- const tooltipIcon = screen.getByLabelText('Instance ID tooltip');
318
- fireEvent.mouseEnter(tooltipIcon);
319
-
320
- const tooltip = await screen.findByText('The ID of current server instance');
321
- expect(tooltip.closest('.ant-tooltip')).toHaveClass('ant-tooltip-placement-top');
322
- });
323
-
324
270
  it('should support inline search input when children is null and keep lazy expand', async () => {
325
271
  const flowContext = createTestFlowContext();
326
272
  const loadOrgChildren = vi.fn(async () => [
@@ -459,13 +405,10 @@ describe('FlowContextSelector', () => {
459
405
 
460
406
  const clearIcon = document.querySelector('.ant-input-clear-icon') as HTMLElement | null;
461
407
  expect(clearIcon).toBeInTheDocument();
462
- if (!clearIcon) {
463
- throw new Error('Expected inline clear icon to be present');
464
- }
465
408
 
466
- fireEvent.mouseDown(clearIcon);
467
- fireEvent.mouseUp(clearIcon);
468
- fireEvent.click(clearIcon);
409
+ fireEvent.mouseDown(clearIcon!);
410
+ fireEvent.mouseUp(clearIcon!);
411
+ fireEvent.click(clearIcon!);
469
412
 
470
413
  await waitFor(() => {
471
414
  expect(onChange).toHaveBeenCalledWith('', undefined);