@nocobase/flow-engine 2.2.0-beta.14 → 2.2.0-beta.16

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 (41) 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 +55 -12
  4. package/lib/components/FormItem.js +11 -7
  5. package/lib/components/MobilePopup.js +14 -3
  6. package/lib/components/subModel/LazyDropdown.js +41 -26
  7. package/lib/components/variables/VariableHybridInput.d.ts +9 -0
  8. package/lib/components/variables/VariableHybridInput.js +146 -17
  9. package/lib/components/variables/VariableInput.js +19 -7
  10. package/lib/components/variables/VariableTag.js +48 -36
  11. package/lib/components/variables/types.d.ts +21 -0
  12. package/lib/flowI18n.js +3 -3
  13. package/lib/locale/en-US.json +2 -0
  14. package/lib/locale/index.d.ts +4 -0
  15. package/lib/locale/zh-CN.json +2 -0
  16. package/lib/types.d.ts +3 -1
  17. package/lib/types.js +1 -0
  18. package/package.json +4 -4
  19. package/src/__tests__/flowI18n.test.ts +11 -0
  20. package/src/acl/Acl.tsx +36 -1
  21. package/src/acl/__tests__/Acl.test.tsx +70 -0
  22. package/src/components/FlowContextSelector.tsx +66 -11
  23. package/src/components/FormItem.tsx +12 -7
  24. package/src/components/MobilePopup.tsx +16 -4
  25. package/src/components/__tests__/FormItem.test.tsx +17 -2
  26. package/src/components/__tests__/MobilePopup.test.tsx +42 -1
  27. package/src/components/subModel/LazyDropdown.tsx +44 -26
  28. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
  29. package/src/components/variables/VariableHybridInput.tsx +185 -14
  30. package/src/components/variables/VariableInput.tsx +32 -7
  31. package/src/components/variables/VariableTag.tsx +51 -37
  32. package/src/components/variables/__tests__/FlowContextSelector.test.tsx +60 -3
  33. package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
  34. package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
  35. package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
  36. package/src/components/variables/types.ts +21 -0
  37. package/src/flowI18n.ts +8 -3
  38. package/src/locale/__tests__/index.test.ts +21 -0
  39. package/src/locale/en-US.json +2 -0
  40. package/src/locale/zh-CN.json +2 -0
  41. package/src/types.ts +2 -0
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { css } from '@emotion/css';
11
- import { Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
11
+ import { ConfigProvider, Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
12
12
  import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
13
  import { useFlowEngine } from '../../provider';
14
14
 
@@ -484,13 +484,10 @@ const createSearchItem = (
484
484
  if (shouldActivateSearchSubmenu) {
485
485
  activateSearchSubmenu(searchKey);
486
486
  }
487
- if ((e.nativeEvent as any)?.isComposing || searchHandlers.isComposing(searchKey)) {
487
+ if ((e.nativeEvent as InputEvent).isComposing || searchHandlers.isComposing(searchKey)) {
488
488
  searchHandlers.updateInputValue(searchKey, value);
489
489
  return;
490
490
  }
491
- if (!value && shouldActivateSearchSubmenu) {
492
- deactivateSearchSubmenu(searchKey);
493
- }
494
491
  searchHandlers.updateSearchValue(searchKey, value);
495
492
  }}
496
493
  onCompositionStart={(e) => {
@@ -504,11 +501,7 @@ const createSearchItem = (
504
501
  e.stopPropagation();
505
502
  const value = e.currentTarget.value;
506
503
  if (shouldActivateSearchSubmenu) {
507
- if (value) {
508
- activateSearchSubmenu(searchKey);
509
- } else {
510
- deactivateSearchSubmenu(searchKey);
511
- }
504
+ activateSearchSubmenu(searchKey);
512
505
  }
513
506
  searchHandlers.endComposition(searchKey, value);
514
507
  }}
@@ -516,11 +509,21 @@ const createSearchItem = (
516
509
  e.stopPropagation();
517
510
  }}
518
511
  onKeyDown={(e) => {
512
+ if (e.key === 'Escape' || e.key === 'Tab') {
513
+ deactivateSearchSubmenu(searchKey);
514
+ return;
515
+ }
516
+ if (shouldActivateSearchSubmenu) {
517
+ activateSearchSubmenu(searchKey);
518
+ }
519
519
  e.stopPropagation();
520
520
  }}
521
521
  onMouseDown={(e) => {
522
522
  // 防止菜单聚焦丢失或页面滚动
523
523
  e.stopPropagation();
524
+ if (shouldActivateSearchSubmenu) {
525
+ activateSearchSubmenu(searchKey);
526
+ }
524
527
  }}
525
528
  size="small"
526
529
  style={{
@@ -552,7 +555,7 @@ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
552
555
 
553
556
  // 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
554
557
  const DROPDOWN_PERSIST_TTL_MS = 350;
555
- const SUBMENU_CLOSE_DELAY = 0.05;
558
+ const MENU_CLOSE_DELAY = 0.3;
556
559
  const SUBMENU_MOTION_DISABLED = {
557
560
  motionEnter: false,
558
561
  motionLeave: false,
@@ -561,13 +564,20 @@ const dropdownPersistRegistry: Map<string, number> = new Map();
561
564
 
562
565
  const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
563
566
  const engine = useFlowEngine();
567
+ const { getPrefixCls } = React.useContext(ConfigProvider.ConfigContext);
568
+ const triggerId = React.useId();
564
569
  const [menuVisible, setMenuVisible] = useState(false);
565
570
  const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
566
- const [activeSearchKey, setActiveSearchKey] = useState<string | null>(null);
567
571
  const [rootItems, setRootItems] = useState<Item[]>([]);
568
572
  const [rootLoading, setRootLoading] = useState(false);
573
+ const activeSearchKeyRef = useRef<string | null>(null);
569
574
  const closeByOutsideClickRef = useRef(false);
570
575
  const skipPreserveActiveSearchRef = useRef(false);
576
+ const triggerOpenClassName = `nb-lazy-dropdown-trigger-${triggerId.replace(/[^a-zA-Z0-9_-]/g, '')}`;
577
+ const defaultOpenClassName = `${getPrefixCls('dropdown', props.prefixCls)}-open`;
578
+ const mergedOpenClassName = [props.openClassName ?? defaultOpenClassName, triggerOpenClassName]
579
+ .filter(Boolean)
580
+ .join(' ');
571
581
  const dropdownMaxHeight = useNiceDropdownMaxHeight();
572
582
  const t = engine.translate.bind(engine);
573
583
 
@@ -589,13 +599,13 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
589
599
 
590
600
  const closeMenu = useCallback(() => {
591
601
  setMenuVisible(false);
592
- setActiveSearchKey(null);
602
+ activeSearchKeyRef.current = null;
593
603
  setOpenKeys(new Set());
594
604
  clearAllSearchValues();
595
605
  }, [clearAllSearchValues]);
596
606
 
597
607
  const activateSearchSubmenu = useCallback((key: string) => {
598
- setActiveSearchKey(key);
608
+ activeSearchKeyRef.current = key;
599
609
  setOpenKeys((prev) => {
600
610
  if (prev.has(key)) return prev;
601
611
  const next = new Set(prev);
@@ -605,11 +615,14 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
605
615
  }, []);
606
616
 
607
617
  const deactivateSearchSubmenu = useCallback((key: string) => {
608
- setActiveSearchKey((prev) => (prev === key ? null : prev));
618
+ if (activeSearchKeyRef.current === key) {
619
+ activeSearchKeyRef.current = null;
620
+ }
609
621
  }, []);
610
622
 
611
623
  const closeActiveSearchForPath = useCallback(
612
624
  (keyPath: string) => {
625
+ const activeSearchKey = activeSearchKeyRef.current;
613
626
  if (
614
627
  !activeSearchKey ||
615
628
  keyPath === activeSearchKey ||
@@ -621,25 +634,26 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
621
634
 
622
635
  skipPreserveActiveSearchRef.current = true;
623
636
  clearSearchValue(activeSearchKey);
624
- setActiveSearchKey(null);
637
+ activeSearchKeyRef.current = null;
625
638
  setOpenKeys((prev) => {
626
639
  const next = new Set(prev);
627
640
  next.delete(activeSearchKey);
628
641
  return next;
629
642
  });
630
643
  },
631
- [activeSearchKey, clearSearchValue],
644
+ [clearSearchValue],
632
645
  );
633
646
 
634
647
  const handleMenuOpenChange = useCallback(
635
648
  (nextOpenKeys: string[]) => {
636
649
  let normalized = normalizeOpenKeys(nextOpenKeys);
637
- if (activeSearchKey && openKeys.has(activeSearchKey) && !normalized.includes(activeSearchKey)) {
638
- if (normalized.length || skipPreserveActiveSearchRef.current) {
650
+ const activeSearchKey = activeSearchKeyRef.current;
651
+ if (activeSearchKey && !normalized.includes(activeSearchKey)) {
652
+ if (skipPreserveActiveSearchRef.current) {
639
653
  clearSearchValue(activeSearchKey);
640
- setActiveSearchKey(null);
654
+ activeSearchKeyRef.current = null;
641
655
  } else {
642
- normalized = [activeSearchKey];
656
+ normalized = Array.from(openKeys);
643
657
  }
644
658
  }
645
659
 
@@ -658,7 +672,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
658
672
  dropdownMenuProps.onOpenChange?.(normalized);
659
673
  skipPreserveActiveSearchRef.current = false;
660
674
  },
661
- [activeSearchKey, clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
675
+ [clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
662
676
  );
663
677
 
664
678
  useEffect(() => {
@@ -666,7 +680,9 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
666
680
 
667
681
  const markOutsideClick = (event: MouseEvent | PointerEvent) => {
668
682
  const target = event.target as HTMLElement | null;
669
- const isOutside = !target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
683
+ const isInsidePopup = target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
684
+ const isInsideCurrentTrigger = target?.closest(`.${triggerOpenClassName}`);
685
+ const isOutside = !isInsidePopup && !isInsideCurrentTrigger;
670
686
  closeByOutsideClickRef.current = isOutside;
671
687
  if (isOutside) {
672
688
  closeMenu();
@@ -679,7 +695,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
679
695
  document.removeEventListener('pointerdown', markOutsideClick, true);
680
696
  document.removeEventListener('mousedown', markOutsideClick, true);
681
697
  };
682
- }, [closeMenu, menuVisible]);
698
+ }, [closeMenu, menuVisible, triggerOpenClassName]);
683
699
 
684
700
  // 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
685
701
  useEffect(() => {
@@ -957,13 +973,15 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
957
973
  {...props}
958
974
  open={menuVisible}
959
975
  destroyPopupOnHide
976
+ mouseLeaveDelay={props.mouseLeaveDelay ?? MENU_CLOSE_DELAY}
977
+ openClassName={mergedOpenClassName}
960
978
  overlayClassName={overlayClassName}
961
979
  placement="bottomLeft"
962
980
  menu={{
963
981
  ...dropdownMenuProps,
964
982
  openKeys: Array.from(openKeys),
965
983
  items: items,
966
- subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? SUBMENU_CLOSE_DELAY,
984
+ subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? MENU_CLOSE_DELAY,
967
985
  motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
968
986
  onClick: () => {},
969
987
  onOpenChange: handleMenuOpenChange,
@@ -974,7 +992,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
974
992
  },
975
993
  }}
976
994
  onOpenChange={(visible, info) => {
977
- if (!visible && activeSearchKey && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
995
+ if (!visible && activeSearchKeyRef.current && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
978
996
  return;
979
997
  }
980
998
 
@@ -411,7 +411,7 @@ describe('transformItems - searchable flags', () => {
411
411
  await waitFor(() => expect(screen.getAllByText('No data').length).toBeGreaterThan(0));
412
412
  });
413
413
 
414
- it('closes searchable submenu after focus without input', async () => {
414
+ it('keeps searchable submenu open while interacting with its input', async () => {
415
415
  const engine = new FlowEngine();
416
416
  await engine.flowSettings.forceEnable();
417
417
  class Parent extends FlowModel {}
@@ -451,7 +451,11 @@ describe('transformItems - searchable flags', () => {
451
451
  await user.click(screen.getByRole('textbox'));
452
452
  fireEvent.mouseLeave(screen.getByText('Fields'));
453
453
 
454
- await waitFor(() => expect(screen.queryByText('Field 1')).not.toBeInTheDocument());
454
+ await act(async () => {
455
+ await new Promise((resolve) => setTimeout(resolve, 350));
456
+ });
457
+
458
+ expect(screen.getByText('Field 1')).toBeInTheDocument();
455
459
  });
456
460
 
457
461
  it('closes active searchable submenu after outside click', async () => {
@@ -499,6 +503,85 @@ describe('transformItems - searchable flags', () => {
499
503
  await waitFor(() => expect(screen.queryByText('Fields')).not.toBeInTheDocument());
500
504
  });
501
505
 
506
+ it('keeps the dropdown open when clicking its already-hovered trigger', async () => {
507
+ const engine = new FlowEngine();
508
+ await engine.flowSettings.forceEnable();
509
+ class Parent extends FlowModel {}
510
+ engine.registerModels({ Parent });
511
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
512
+ const user = userEvent.setup();
513
+
514
+ render(
515
+ <FlowEngineProvider engine={engine}>
516
+ <ConfigProvider>
517
+ <App>
518
+ <AddSubModelButton
519
+ model={parent}
520
+ subModelKey="items"
521
+ items={[
522
+ {
523
+ key: 'child',
524
+ label: 'Child',
525
+ createModelOptions: { use: 'Parent' },
526
+ },
527
+ ]}
528
+ >
529
+ Open
530
+ </AddSubModelButton>
531
+ </App>
532
+ </ConfigProvider>
533
+ </FlowEngineProvider>,
534
+ );
535
+
536
+ const trigger = screen.getByText('Open');
537
+ await user.hover(trigger);
538
+ await waitFor(() => expect(screen.getByText('Child')).toBeInTheDocument());
539
+ expect(trigger).toHaveClass('ant-dropdown-open');
540
+
541
+ await user.click(trigger);
542
+
543
+ expect(screen.getByText('Child')).toBeInTheDocument();
544
+ });
545
+
546
+ it('closes the dropdown when clicking another dropdown trigger', async () => {
547
+ const engine = new FlowEngine();
548
+ await engine.flowSettings.forceEnable();
549
+ class Parent extends FlowModel {}
550
+ engine.registerModels({ Parent });
551
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
552
+ const user = userEvent.setup();
553
+
554
+ render(
555
+ <FlowEngineProvider engine={engine}>
556
+ <ConfigProvider>
557
+ <App>
558
+ <AddSubModelButton
559
+ model={parent}
560
+ subModelKey="firstItems"
561
+ items={[{ key: 'first-child', label: 'First child', createModelOptions: { use: 'Parent' } }]}
562
+ >
563
+ Open first
564
+ </AddSubModelButton>
565
+ <AddSubModelButton
566
+ model={parent}
567
+ subModelKey="secondItems"
568
+ items={[{ key: 'second-child', label: 'Second child', createModelOptions: { use: 'Parent' } }]}
569
+ >
570
+ Open second
571
+ </AddSubModelButton>
572
+ </App>
573
+ </ConfigProvider>
574
+ </FlowEngineProvider>,
575
+ );
576
+
577
+ await user.hover(screen.getByText('Open first'));
578
+ await waitFor(() => expect(screen.getByText('First child')).toBeInTheDocument());
579
+
580
+ fireEvent.pointerDown(screen.getByText('Open second'));
581
+
582
+ await waitFor(() => expect(screen.queryByText('First child')).not.toBeInTheDocument());
583
+ });
584
+
502
585
  it('switches away from active searchable submenu and resets its input', async () => {
503
586
  const engine = new FlowEngine();
504
587
  await engine.flowSettings.forceEnable();
@@ -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
  />