@nocobase/flow-engine 2.2.0-alpha.1 → 2.2.0-alpha.2

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 (84) hide show
  1. package/lib/JSRunner.d.ts +1 -0
  2. package/lib/JSRunner.js +110 -20
  3. package/lib/components/FieldModelRenderer.js +44 -31
  4. package/lib/components/FlowContextSelector.js +24 -3
  5. package/lib/components/dnd/index.js +9 -2
  6. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
  7. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
  8. package/lib/components/variables/VariableHybridInput.d.ts +8 -0
  9. package/lib/components/variables/VariableHybridInput.js +128 -12
  10. package/lib/components/variables/types.d.ts +8 -0
  11. package/lib/flowContext.d.ts +1 -1
  12. package/lib/flowContext.js +87 -36
  13. package/lib/flowI18n.js +3 -3
  14. package/lib/flowSettings.d.ts +5 -1
  15. package/lib/flowSettings.js +70 -0
  16. package/lib/locale/en-US.json +1 -0
  17. package/lib/locale/index.d.ts +2 -0
  18. package/lib/locale/zh-CN.json +1 -0
  19. package/lib/resources/apiResource.js +2 -1
  20. package/lib/resources/baseRecordResource.js +6 -17
  21. package/lib/resources/multiRecordResource.js +13 -3
  22. package/lib/resources/singleRecordResource.js +7 -2
  23. package/lib/runjs-context/helpers.js +12 -5
  24. package/lib/types.d.ts +12 -0
  25. package/lib/utils/dataSourceDirty.d.ts +20 -0
  26. package/lib/utils/dataSourceDirty.js +139 -0
  27. package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
  28. package/lib/utils/dirtyAwareApiClient.js +378 -0
  29. package/lib/utils/index.d.ts +1 -1
  30. package/lib/utils/index.js +11 -11
  31. package/lib/utils/openViewRouteState.d.ts +28 -0
  32. package/lib/utils/openViewRouteState.js +125 -0
  33. package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
  34. package/lib/utils/parsePathnameToViewParams.js +18 -1
  35. package/lib/utils/resolveRunJSObjectValues.js +3 -2
  36. package/lib/utils/runjsModuleLoader.js +0 -30
  37. package/lib/views/ViewNavigation.js +5 -0
  38. package/package.json +4 -4
  39. package/src/JSRunner.ts +112 -25
  40. package/src/__tests__/JSRunner.test.ts +4 -5
  41. package/src/__tests__/flowContext.test.ts +131 -0
  42. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
  43. package/src/__tests__/flowI18n.test.ts +11 -0
  44. package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
  45. package/src/__tests__/flowSettings.test.ts +72 -0
  46. package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
  47. package/src/components/FieldModelRenderer.tsx +50 -36
  48. package/src/components/FlowContextSelector.tsx +33 -2
  49. package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
  50. package/src/components/dnd/index.tsx +11 -2
  51. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
  52. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
  53. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
  54. package/src/components/variables/VariableHybridInput.tsx +166 -9
  55. package/src/components/variables/__tests__/VariableHybridInput.test.tsx +178 -0
  56. package/src/components/variables/types.ts +8 -0
  57. package/src/flowContext.ts +100 -33
  58. package/src/flowI18n.ts +8 -3
  59. package/src/flowSettings.ts +85 -1
  60. package/src/locale/en-US.json +1 -0
  61. package/src/locale/zh-CN.json +1 -0
  62. package/src/resources/apiResource.ts +2 -1
  63. package/src/resources/baseRecordResource.ts +6 -23
  64. package/src/resources/multiRecordResource.ts +13 -3
  65. package/src/resources/singleRecordResource.ts +6 -1
  66. package/src/runjs-context/helpers.ts +12 -6
  67. package/src/types.ts +14 -0
  68. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
  69. package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
  70. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
  71. package/src/utils/dataSourceDirty.ts +126 -0
  72. package/src/utils/dirtyAwareApiClient.ts +430 -0
  73. package/src/utils/index.ts +10 -9
  74. package/src/utils/openViewRouteState.ts +107 -0
  75. package/src/utils/parsePathnameToViewParams.ts +23 -1
  76. package/src/utils/resolveRunJSObjectValues.ts +5 -2
  77. package/src/utils/runjsModuleLoader.ts +0 -32
  78. package/src/views/ViewNavigation.ts +6 -1
  79. package/src/views/__tests__/ViewNavigation.test.ts +15 -0
  80. package/lib/utils/safeGlobals.d.ts +0 -28
  81. package/lib/utils/safeGlobals.js +0 -367
  82. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
  83. package/src/utils/__tests__/safeGlobals.test.ts +0 -106
  84. package/src/utils/safeGlobals.ts +0 -406
@@ -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
 
@@ -37,6 +42,14 @@ export interface VariableHybridInputProps {
37
42
  converters?: VariableHybridInputConverters;
38
43
  style?: React.CSSProperties;
39
44
  className?: string;
45
+ /**
46
+ * Validation status — turns the input border red (`error`) or amber
47
+ * (`warning`). Usually omitted: when rendered inside an antd `Form.Item`, the
48
+ * status is read automatically from `FormItemInputContext`, so dropping this
49
+ * into a `Form.Item` with failing rules colours the border with no extra
50
+ * wiring. An explicit prop wins over the inherited form status.
51
+ */
52
+ status?: 'error' | 'warning';
40
53
  }
41
54
 
42
55
  function reactNodeToPlainText(node: React.ReactNode): string {
@@ -83,14 +96,38 @@ function normalizeVariableKey(value: string): string {
83
96
  .trim();
84
97
  }
85
98
 
86
- function renderHTML(value: string, labelMap: Map<string, string>, regExp: RegExp) {
99
+ function renderHTML(value: string, regExp: RegExp, resolveLabel: (matched: string) => string | undefined) {
87
100
  const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
88
101
  return escapeHtml(value || '').replace(re, (matched) => {
89
- const label = labelMap.get(normalizeVariableKey(matched)) || matched;
102
+ const label = resolveLabel(matched) || matched;
90
103
  return createTagHTML(matched, label);
91
104
  });
92
105
  }
93
106
 
107
+ // Resolve a `{{ … }}` reference path to its slash-joined title chain by walking the (possibly lazily-expanded) meta tree
108
+ // live. Unlike `buildLabelMap` — which pre-walks only already-loaded (array) children into a memoized map — this reads
109
+ // the tree's CURRENT contents at call time, so a level expanded in place (by the cascader's loadData when the user
110
+ // drills in, or by the preload effect) is reflected on the very next render without the memoized map having to rebuild.
111
+ // This is what fixes a just-picked deep node rendering as its raw `{{ … }}` token. Returns undefined if any segment is
112
+ // missing or sits below a still-unresolved (thunk) level — the caller then falls back to the raw token.
113
+ function resolveTitlesByPath(
114
+ roots: MetaTreeNode[] | undefined,
115
+ path: string[] | undefined,
116
+ ctxT: (text: string) => string,
117
+ ): string | undefined {
118
+ if (!roots || !path || !path.length) return undefined;
119
+ const titles: string[] = [];
120
+ let nodes: MetaTreeNode[] | undefined = roots;
121
+ for (const segment of path) {
122
+ if (!nodes) return undefined;
123
+ const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
124
+ if (!matched) return undefined;
125
+ titles.push(reactNodeToPlainText(matched.title || matched.name));
126
+ nodes = Array.isArray(matched.children) ? (matched.children as MetaTreeNode[]) : undefined;
127
+ }
128
+ return titles.map(ctxT).join('/');
129
+ }
130
+
94
131
  function buildLabelMap(
95
132
  nodes: MetaTreeNode[] | undefined,
96
133
  ctxT: (text: string) => string,
@@ -116,6 +153,42 @@ function buildLabelMap(
116
153
  return map;
117
154
  }
118
155
 
156
+ // Collect every variable reference path in `value` (one per `{{ … }}` token). Used to preload lazy meta-tree levels so
157
+ // a saved reference whose label lives below an unexpanded (thunk) level still resolves to a readable tag.
158
+ function collectReferencePaths(
159
+ value: string,
160
+ regExp: RegExp,
161
+ parseValueToPath: (value?: string) => string[] | undefined,
162
+ ): string[][] {
163
+ const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
164
+ const paths: string[][] = [];
165
+ for (const matched of value.match(re) ?? []) {
166
+ const path = parseValueToPath(matched);
167
+ if (path && path.length) {
168
+ paths.push(path);
169
+ }
170
+ }
171
+ return paths;
172
+ }
173
+
174
+ // Walk one reference path down the meta tree, resolving each lazy `children` thunk in place. Returns true if it
175
+ // resolved at least one level (so the caller knows to recompute the label map). Mirrors `TypedVariableInput`'s preload.
176
+ async function preloadReferencePath(path: string[], roots: MetaTreeNode[]): Promise<boolean> {
177
+ let nodes: MetaTreeNode[] | undefined = roots;
178
+ let didLoad = false;
179
+ for (const segment of path) {
180
+ if (!nodes) break;
181
+ const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
182
+ if (!matched) break;
183
+ if (typeof matched.children === 'function') {
184
+ matched.children = await loadMetaTreeChildren(matched);
185
+ didLoad = true;
186
+ }
187
+ nodes = Array.isArray(matched.children) ? matched.children : undefined;
188
+ }
189
+ return didLoad;
190
+ }
191
+
119
192
  function pasteHTML(container: HTMLElement, html: string, indexes?: RangeIndexes) {
120
193
  const selection = window.getSelection?.();
121
194
  const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
@@ -226,6 +299,10 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
226
299
  const { token } = theme.useToken();
227
300
  const ctx = useFlowContext();
228
301
  const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
302
+ // Inherit the antd Form.Item validation status (red/amber border) unless an explicit `status` prop overrides it — so
303
+ // the border colours automatically inside a failing `Form.Item`, no extra wiring for callers.
304
+ const formItemStatus = useContext(FormItemInputContext)?.status;
305
+ const effectiveStatus = props.status ?? formItemStatus;
229
306
  const inputRef = useRef<HTMLDivElement>(null);
230
307
  const [isComposing, setIsComposing] = useState(false);
231
308
  const [changed, setChanged] = useState(false);
@@ -233,13 +310,63 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
233
310
 
234
311
  const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : String(props.value);
235
312
  const variableRegExp = converters?.variableRegExp ?? DEFAULT_VARIABLE_REGEXP;
313
+ const parseValueToPath = converters?.parseValueToPath ?? defaultParseValueToPath;
314
+
315
+ // Bumped after a saved reference's lazy meta-tree levels are resolved, so the label map (below) recomputes once the
316
+ // deep titles are actually loaded.
317
+ const [loadedFlag, setLoadedFlag] = useState(0);
318
+
319
+ // Preload the lazy levels every `{{ … }}` reference in `value` points through. `buildLabelMap` only walks
320
+ // already-loaded (array) children, so without this a reference below an unexpanded thunk level (e.g. a workflow
321
+ // node's output fields under `$jobsMapByNodeKey.<nodeKey>`) renders as the raw `{{ … }}` text instead of its
322
+ // node/field labels. Mirrors `TypedVariableInput`'s preload.
323
+ useEffect(() => {
324
+ if (!value || !Array.isArray(resolvedMetaTree) || !resolvedMetaTree.length) {
325
+ return;
326
+ }
327
+ const paths = collectReferencePaths(value, variableRegExp, parseValueToPath);
328
+ if (!paths.length) {
329
+ return;
330
+ }
331
+ let cancelled = false;
332
+ const run = async () => {
333
+ let didLoad = false;
334
+ for (const path of paths) {
335
+ const loaded = await preloadReferencePath(path, resolvedMetaTree as MetaTreeNode[]);
336
+ if (cancelled) return;
337
+ didLoad = didLoad || loaded;
338
+ }
339
+ if (didLoad && !cancelled) {
340
+ setLoadedFlag((prev) => prev + 1);
341
+ }
342
+ };
343
+ run();
344
+ return () => {
345
+ cancelled = true;
346
+ };
347
+ }, [value, resolvedMetaTree, variableRegExp, parseValueToPath]);
236
348
 
237
349
  const labelMap = useMemo(
238
350
  () => buildLabelMap(resolvedMetaTree as MetaTreeNode[] | undefined, ctx.t, converters),
239
- [resolvedMetaTree, ctx, converters],
351
+ // `loadedFlag` is read so the map recomputes after a lazy level resolves.
352
+ // eslint-disable-next-line react-hooks/exhaustive-deps
353
+ [resolvedMetaTree, ctx, converters, loadedFlag],
354
+ );
355
+
356
+ // 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.
357
+ const resolveLabel = useCallback(
358
+ (matched: string): string | undefined => {
359
+ const mapped = labelMap.get(normalizeVariableKey(matched));
360
+ if (mapped) return mapped;
361
+ const path = parseValueToPath(matched);
362
+ return resolveTitlesByPath(resolvedMetaTree as MetaTreeNode[] | undefined, path, ctx.t);
363
+ },
364
+ // `loadedFlag` is read so a resolved lazy level re-creates this callback and re-renders the tags. `ctx` carries the translation fn.
365
+ // eslint-disable-next-line react-hooks/exhaustive-deps
366
+ [labelMap, parseValueToPath, resolvedMetaTree, ctx, loadedFlag],
240
367
  );
241
368
 
242
- const [html, setHtml] = useState(() => renderHTML(value, labelMap, variableRegExp));
369
+ const [html, setHtml] = useState(() => renderHTML(value, variableRegExp, resolveLabel));
243
370
 
244
371
  const emitChange = useCallback(
245
372
  (target: HTMLElement) => {
@@ -249,12 +376,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
249
376
  );
250
377
 
251
378
  useEffect(() => {
252
- setHtml(renderHTML(value, labelMap, variableRegExp));
379
+ setHtml(renderHTML(value, variableRegExp, resolveLabel));
253
380
  if (!changed) {
254
381
  setRange([-1, 0, -1, 0]);
255
382
  }
256
383
  // eslint-disable-next-line react-hooks/exhaustive-deps
257
- }, [value, labelMap]);
384
+ }, [value, resolveLabel]);
258
385
 
259
386
  // Restore caret position after html update
260
387
  useEffect(() => {
@@ -492,6 +619,34 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
492
619
  border-color: ${token.colorBorder};
493
620
  }
494
621
  }
622
+
623
+ &.is-error {
624
+ border-color: ${token.colorError};
625
+
626
+ &:hover {
627
+ border-color: ${token.colorErrorBorderHover};
628
+ }
629
+
630
+ &:focus,
631
+ &:focus-visible {
632
+ border-color: ${token.colorError};
633
+ box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.colorErrorOutline};
634
+ }
635
+ }
636
+
637
+ &.is-warning {
638
+ border-color: ${token.colorWarning};
639
+
640
+ &:hover {
641
+ border-color: ${token.colorWarningBorderHover};
642
+ }
643
+
644
+ &:focus,
645
+ &:focus-visible {
646
+ border-color: ${token.colorWarning};
647
+ box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.colorWarningOutline};
648
+ }
649
+ }
495
650
  `;
496
651
  }, [token, addonBefore]);
497
652
 
@@ -505,6 +660,8 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
505
660
  aria-label="textbox"
506
661
  className={cx(editorClassName, {
507
662
  'is-disabled': disabled,
663
+ 'is-error': effectiveStatus === 'error',
664
+ 'is-warning': effectiveStatus === 'warning',
508
665
  })}
509
666
  contentEditable={!disabled}
510
667
  data-placeholder={placeholder}
@@ -519,7 +676,7 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
519
676
  <FlowContextSelector
520
677
  metaTree={metaTree}
521
678
  disabled={disabled}
522
- parseValueToPath={converters?.parseValueToPath ?? defaultParseValueToPath}
679
+ parseValueToPath={parseValueToPath}
523
680
  formatPathToValue={(item) => converters?.formatPathToValue?.(item) || defaultFormatPathToValue(item)}
524
681
  onChange={handleSelectorChange}
525
682
  />
@@ -0,0 +1,178 @@
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
+ /**
11
+ * Pins how a `{{ … }}` reference renders as its label when the label lives below a lazily-loaded meta-tree level (e.g. a workflow node's output fields under `$jobsMapByNodeKey.<nodeKey>`). `buildLabelMap` only pre-walks already-loaded (array) children into a memoized map, so the two real user flows each need their own resolution path, both pinned here:
12
+ * - after reload — the value is already a deep reference at mount, its level still an unresolved thunk → the preload effect resolves it.
13
+ * - after picking — the user drills into a lazy level (cascader resolves it IN PLACE, no tree-ref change) then picks a leaf → the live walk of the tree's current contents resolves it on the same render.
14
+ * Plus the top-level (already-loaded) and not-in-tree (raw-token fallback) cases.
15
+ */
16
+
17
+ import { render, waitFor } from '@testing-library/react';
18
+ import React from 'react';
19
+ import { describe, expect, it, vi } from 'vitest';
20
+ import { VariableHybridInput, type VariableHybridInputConverters } from '../VariableHybridInput';
21
+ import type { MetaTreeNode } from '../../../flowContext';
22
+ import { createTestFlowContext, TestFlowContextWrapper } from './test-utils';
23
+
24
+ // Workflow-style converters: `{{$root.a.b}}` (dotted path, no inner spaces).
25
+ const VARIABLE_REGEXP = /\{\{\s*([^{}]+?)\s*\}\}/g;
26
+ const workflowConverters: VariableHybridInputConverters = {
27
+ formatPathToValue: (item?: MetaTreeNode) => {
28
+ const path = item?.paths ?? [];
29
+ return path.length ? `{{${path.join('.')}}}` : '';
30
+ },
31
+ parseValueToPath: (value?: string) => {
32
+ if (typeof value !== 'string') return undefined;
33
+ const match = value.trim().match(/^\{\{\s*(.+?)\s*\}\}$/);
34
+ return match ? match[1].split('.') : undefined;
35
+ },
36
+ variableRegExp: VARIABLE_REGEXP,
37
+ };
38
+
39
+ const TAG_SELECTOR = '.nb-variable-tag';
40
+
41
+ describe('VariableHybridInput — saved reference labels', () => {
42
+ it('renders a top-level (already-loaded) reference as its label, not the raw token', async () => {
43
+ const flowContext = createTestFlowContext();
44
+ const metaTree: MetaTreeNode[] = [
45
+ {
46
+ name: '$user',
47
+ title: 'User',
48
+ type: '',
49
+ paths: ['$user'],
50
+ children: [{ name: 'name', title: 'Name', type: 'string', paths: ['$user', 'name'] }],
51
+ },
52
+ ];
53
+
54
+ render(
55
+ <TestFlowContextWrapper context={flowContext}>
56
+ <VariableHybridInput value="{{$user.name}}" metaTree={metaTree} converters={workflowConverters} />
57
+ </TestFlowContextWrapper>,
58
+ );
59
+
60
+ await waitFor(() => {
61
+ const tag = document.querySelector(TAG_SELECTOR);
62
+ expect(tag).toBeTruthy();
63
+ expect(tag?.textContent).toBe('User/Name');
64
+ });
65
+ });
66
+
67
+ it('shows the label after reload: a deep reference present at mount preloads its lazy level', async () => {
68
+ const flowContext = createTestFlowContext();
69
+ // The node's output fields are behind a lazy `children` thunk — exactly the `$jobsMapByNodeKey.<nodeKey>` shape produced by the workflow adapter.
70
+ const loadChildren = vi.fn(
71
+ async (): Promise<MetaTreeNode[]> => [
72
+ { name: 'name', title: 'Name', type: 'string', paths: ['$jobsMapByNodeKey', 'node1', 'name'] },
73
+ ],
74
+ );
75
+ const metaTree: MetaTreeNode[] = [
76
+ {
77
+ name: '$jobsMapByNodeKey',
78
+ title: 'Node result',
79
+ type: '',
80
+ paths: ['$jobsMapByNodeKey'],
81
+ children: [
82
+ {
83
+ name: 'node1',
84
+ title: 'Query',
85
+ type: '',
86
+ paths: ['$jobsMapByNodeKey', 'node1'],
87
+ children: loadChildren,
88
+ },
89
+ ],
90
+ },
91
+ ];
92
+
93
+ render(
94
+ <TestFlowContextWrapper context={flowContext}>
95
+ <VariableHybridInput
96
+ value="{{$jobsMapByNodeKey.node1.name}}"
97
+ metaTree={metaTree}
98
+ converters={workflowConverters}
99
+ />
100
+ </TestFlowContextWrapper>,
101
+ );
102
+
103
+ // The lazy thunk is invoked by the preload, and the deep label appears.
104
+ await waitFor(() => {
105
+ expect(loadChildren).toHaveBeenCalled();
106
+ const tag = document.querySelector(TAG_SELECTOR);
107
+ expect(tag?.textContent).toBe('Node result/Query/Name');
108
+ });
109
+ // It must NOT leave the raw token visible.
110
+ expect(document.body.textContent).not.toContain('{{$jobsMapByNodeKey.node1.name}}');
111
+ });
112
+
113
+ it('shows the label after picking: a level expanded in place then selected resolves live', async () => {
114
+ // Reproduces drilling into a lazy level in the cascader: that resolves the node's `children` onto the SAME meta-tree object in place — WITHOUT changing the tree reference — then the user picks a leaf, so `value` updates. The memoized `labelMap` (keyed on the tree reference) still misses the freshly loaded leaf; only a live walk of the tree's current contents resolves it.
115
+ const flowContext = createTestFlowContext();
116
+ const node1: MetaTreeNode = {
117
+ name: 'node1',
118
+ title: 'Query',
119
+ type: '',
120
+ paths: ['$jobsMapByNodeKey', 'node1'],
121
+ // Starts WITHOUT children (an unexpanded branch). No thunk — so the preload effect/`loadedFlag` path does not fire; the fix must come from the live walk.
122
+ };
123
+ const metaTree: MetaTreeNode[] = [
124
+ {
125
+ name: '$jobsMapByNodeKey',
126
+ title: 'Node result',
127
+ type: '',
128
+ paths: ['$jobsMapByNodeKey'],
129
+ children: [node1],
130
+ },
131
+ ];
132
+
133
+ // Mount with no reference yet — the deep level is not loaded.
134
+ const { rerender } = render(
135
+ <TestFlowContextWrapper context={flowContext}>
136
+ <VariableHybridInput value="" metaTree={metaTree} converters={workflowConverters} />
137
+ </TestFlowContextWrapper>,
138
+ );
139
+
140
+ // Simulate the cascader's loadData: resolve node1's children in place on the SAME tree object (no new reference, no thunk).
141
+ node1.children = [
142
+ { name: 'name', title: 'Role name', type: 'string', paths: ['$jobsMapByNodeKey', 'node1', 'name'] },
143
+ ];
144
+
145
+ // The user picks the leaf → value updates to the deep reference.
146
+ rerender(
147
+ <TestFlowContextWrapper context={flowContext}>
148
+ <VariableHybridInput
149
+ value="{{$jobsMapByNodeKey.node1.name}}"
150
+ metaTree={metaTree}
151
+ converters={workflowConverters}
152
+ />
153
+ </TestFlowContextWrapper>,
154
+ );
155
+
156
+ await waitFor(() => {
157
+ const tag = document.querySelector(TAG_SELECTOR);
158
+ expect(tag?.textContent).toBe('Node result/Query/Role name');
159
+ });
160
+ expect(document.body.textContent).not.toContain('{{$jobsMapByNodeKey.node1.name}}');
161
+ });
162
+
163
+ it('falls back to the raw token when the reference is not in the tree', async () => {
164
+ const flowContext = createTestFlowContext();
165
+ const metaTree: MetaTreeNode[] = [{ name: '$user', title: 'User', type: '', paths: ['$user'], children: [] }];
166
+
167
+ render(
168
+ <TestFlowContextWrapper context={flowContext}>
169
+ <VariableHybridInput value="{{$missing.field}}" metaTree={metaTree} converters={workflowConverters} />
170
+ </TestFlowContextWrapper>,
171
+ );
172
+
173
+ await waitFor(() => {
174
+ const tag = document.querySelector(TAG_SELECTOR);
175
+ expect(tag?.textContent).toBe('{{$missing.field}}');
176
+ });
177
+ });
178
+ });
@@ -22,6 +22,14 @@ export interface FlowContextSelectorProps
22
22
  open?: boolean;
23
23
  onlyLeafSelectable?: boolean;
24
24
  ignoreFieldNames?: string[];
25
+ /**
26
+ * Footer rendered at the bottom of the dropdown. Defaults to a muted
27
+ * "Double click to choose entire object" hint when non-leaf selection is
28
+ * allowed (`onlyLeafSelectable` is false) — since double-clicking a non-leaf
29
+ * node selects the whole object. Pass an explicit node to override, or `null`
30
+ * to hide it.
31
+ */
32
+ dropdownFooter?: React.ReactNode;
25
33
  }
26
34
 
27
35
  export interface ContextSelectorItem {