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

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 (91) 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 +27 -5
  5. package/lib/components/MobilePopup.style.js +16 -5
  6. package/lib/components/dnd/index.js +9 -2
  7. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
  8. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
  9. package/lib/components/variables/VariableHybridInput.d.ts +8 -0
  10. package/lib/components/variables/VariableHybridInput.js +128 -12
  11. package/lib/components/variables/VariableInput.js +2 -1
  12. package/lib/components/variables/types.d.ts +18 -0
  13. package/lib/flowContext.d.ts +1 -1
  14. package/lib/flowContext.js +87 -36
  15. package/lib/flowI18n.js +3 -3
  16. package/lib/flowSettings.d.ts +5 -1
  17. package/lib/flowSettings.js +70 -0
  18. package/lib/locale/en-US.json +1 -0
  19. package/lib/locale/index.d.ts +2 -0
  20. package/lib/locale/zh-CN.json +1 -0
  21. package/lib/resources/apiResource.js +2 -1
  22. package/lib/resources/baseRecordResource.js +6 -17
  23. package/lib/resources/multiRecordResource.js +13 -3
  24. package/lib/resources/singleRecordResource.js +7 -2
  25. package/lib/runjs-context/helpers.js +12 -5
  26. package/lib/types.d.ts +12 -0
  27. package/lib/utils/dataSourceDirty.d.ts +20 -0
  28. package/lib/utils/dataSourceDirty.js +139 -0
  29. package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
  30. package/lib/utils/dirtyAwareApiClient.js +378 -0
  31. package/lib/utils/index.d.ts +1 -1
  32. package/lib/utils/index.js +11 -11
  33. package/lib/utils/openViewRouteState.d.ts +28 -0
  34. package/lib/utils/openViewRouteState.js +125 -0
  35. package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
  36. package/lib/utils/parsePathnameToViewParams.js +18 -1
  37. package/lib/utils/resolveRunJSObjectValues.js +3 -2
  38. package/lib/utils/runjsModuleLoader.js +0 -30
  39. package/lib/views/ViewNavigation.js +5 -0
  40. package/package.json +4 -4
  41. package/src/JSRunner.ts +112 -25
  42. package/src/__tests__/JSRunner.test.ts +4 -5
  43. package/src/__tests__/flowContext.test.ts +131 -0
  44. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
  45. package/src/__tests__/flowI18n.test.ts +11 -0
  46. package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
  47. package/src/__tests__/flowSettings.test.ts +72 -0
  48. package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
  49. package/src/components/FieldModelRenderer.tsx +50 -36
  50. package/src/components/FlowContextSelector.tsx +36 -4
  51. package/src/components/MobilePopup.style.ts +22 -6
  52. package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
  53. package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
  54. package/src/components/dnd/index.tsx +11 -2
  55. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
  56. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
  57. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
  58. package/src/components/variables/VariableHybridInput.tsx +166 -9
  59. package/src/components/variables/VariableInput.tsx +2 -1
  60. package/src/components/variables/__tests__/FlowContextSelector.test.tsx +23 -3
  61. package/src/components/variables/__tests__/VariableHybridInput.test.tsx +178 -0
  62. package/src/components/variables/__tests__/VariableInput.test.tsx +51 -5
  63. package/src/components/variables/types.ts +18 -0
  64. package/src/flowContext.ts +100 -33
  65. package/src/flowI18n.ts +8 -3
  66. package/src/flowSettings.ts +85 -1
  67. package/src/locale/en-US.json +1 -0
  68. package/src/locale/zh-CN.json +1 -0
  69. package/src/resources/apiResource.ts +2 -1
  70. package/src/resources/baseRecordResource.ts +6 -23
  71. package/src/resources/multiRecordResource.ts +13 -3
  72. package/src/resources/singleRecordResource.ts +6 -1
  73. package/src/runjs-context/helpers.ts +12 -6
  74. package/src/types.ts +14 -0
  75. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
  76. package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
  77. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
  78. package/src/utils/dataSourceDirty.ts +126 -0
  79. package/src/utils/dirtyAwareApiClient.ts +430 -0
  80. package/src/utils/index.ts +10 -9
  81. package/src/utils/openViewRouteState.ts +107 -0
  82. package/src/utils/parsePathnameToViewParams.ts +23 -1
  83. package/src/utils/resolveRunJSObjectValues.ts +5 -2
  84. package/src/utils/runjsModuleLoader.ts +0 -32
  85. package/src/views/ViewNavigation.ts +6 -1
  86. package/src/views/__tests__/ViewNavigation.test.ts +15 -0
  87. package/lib/utils/safeGlobals.d.ts +0 -28
  88. package/lib/utils/safeGlobals.js +0 -367
  89. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
  90. package/src/utils/__tests__/safeGlobals.test.ts +0 -106
  91. 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
  />
@@ -192,7 +192,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
192
192
  };
193
193
 
194
194
  restoreFromValue();
195
- }, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode]);
195
+ }, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode, value]);
196
196
 
197
197
  const ValueComponent = useMemo(() => {
198
198
  const Component = renderInputComponent?.(resolvedMetaTreeNode);
@@ -356,6 +356,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
356
356
  <FlowContextSelector
357
357
  metaTree={resolvedMetaTree}
358
358
  value={innerValue}
359
+ active={isVariableValue(innerValue)}
359
360
  onChange={handleVariableSelect}
360
361
  parseValueToPath={resolvePathFromValue}
361
362
  formatPathToValue={resolveValueFromPath}
@@ -67,6 +67,23 @@ 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
+
70
87
  it('should support function metaTree loading', async () => {
71
88
  const flowContext = createTestFlowContext();
72
89
  const metaTreeFn = vi.fn(() => flowContext.getPropertyMetaTree());
@@ -405,10 +422,13 @@ describe('FlowContextSelector', () => {
405
422
 
406
423
  const clearIcon = document.querySelector('.ant-input-clear-icon') as HTMLElement | null;
407
424
  expect(clearIcon).toBeInTheDocument();
425
+ if (!clearIcon) {
426
+ throw new Error('Expected inline clear icon to be present');
427
+ }
408
428
 
409
- fireEvent.mouseDown(clearIcon!);
410
- fireEvent.mouseUp(clearIcon!);
411
- fireEvent.click(clearIcon!);
429
+ fireEvent.mouseDown(clearIcon);
430
+ fireEvent.mouseUp(clearIcon);
431
+ fireEvent.click(clearIcon);
412
432
 
413
433
  await waitFor(() => {
414
434
  expect(onChange).toHaveBeenCalledWith('', undefined);
@@ -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
+ });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
11
+ import { Input } from 'antd';
11
12
  import React from 'react';
12
13
  import { describe, expect, it, vi } from 'vitest';
13
14
  import type { ContextSelectorItem } from '../types';
@@ -112,6 +113,50 @@ describe('VariableInput', () => {
112
113
  expect(selectorButton).toBeInTheDocument();
113
114
  });
114
115
 
116
+ it('should not highlight the selector button for synthetic constant/null paths', async () => {
117
+ const flowContext = createTestFlowContext();
118
+
119
+ render(
120
+ <TestFlowContextWrapper context={flowContext}>
121
+ <VariableInput
122
+ value=""
123
+ metaTree={[
124
+ { name: 'constant', title: 'Constant', type: 'string', paths: ['constant'] },
125
+ { name: 'null', title: 'Null', type: 'object', paths: ['null'] },
126
+ ...flowContext.getPropertyMetaTree(),
127
+ ]}
128
+ converters={{
129
+ renderInputComponent: (meta) => {
130
+ const first = meta?.paths?.[0];
131
+ if (first === 'constant') {
132
+ return (props: any) => <input aria-label="constant-value" {...props} />;
133
+ }
134
+ if (first === 'null') {
135
+ return () => <Input placeholder="<Null>" readOnly />;
136
+ }
137
+ return null;
138
+ },
139
+ resolveValueFromPath: (meta) => {
140
+ const first = meta?.paths?.[0];
141
+ if (first === 'constant') return '';
142
+ if (first === 'null') return null;
143
+ return undefined;
144
+ },
145
+ resolvePathFromValue: (currentValue) => {
146
+ if (currentValue === null) return ['null'];
147
+ const trimmed = typeof currentValue === 'string' ? currentValue.trim() : currentValue;
148
+ if (trimmed === '') return ['constant'];
149
+ return undefined;
150
+ },
151
+ }}
152
+ />
153
+ </TestFlowContextWrapper>,
154
+ );
155
+
156
+ const selectorButton = await screen.findByRole('button');
157
+ expect(selectorButton.className).not.toContain('ant-btn-primary');
158
+ });
159
+
115
160
  it('should handle onChange from Input', async () => {
116
161
  const onChange = vi.fn();
117
162
  const flowContext = createTestFlowContext();
@@ -195,20 +240,21 @@ describe('VariableInput', () => {
195
240
 
196
241
  const selectElement = container.querySelector('.ant-select');
197
242
  expect(selectElement).toBeInTheDocument();
243
+ if (!selectElement) {
244
+ throw new Error('Expected variable tag select wrapper to be present');
245
+ }
198
246
 
199
247
  // 触发鼠标悬停以显示清除按钮
200
- fireEvent.mouseEnter(selectElement!);
248
+ fireEvent.mouseEnter(selectElement);
201
249
 
202
250
  // 尝试触发清除功能
203
251
  // 方法1: 直接触发 Select 组件的 onClear 事件
204
- const selectInstance = selectElement as any;
205
-
206
252
  // 尝试通过键盘事件触发清除
207
- fireEvent.keyDown(selectElement!, { key: 'Backspace', code: 'Backspace' });
253
+ fireEvent.keyDown(selectElement, { key: 'Backspace', code: 'Backspace' });
208
254
 
209
255
  // 或者尝试触发自定义的清除逻辑
210
256
  const clearEvents = new CustomEvent('clear');
211
- selectElement!.dispatchEvent(clearEvents);
257
+ selectElement.dispatchEvent(clearEvents);
212
258
 
213
259
  // 检查是否调用了清除功能(可能需要调整期望)
214
260
  // 如果清除按钮不能直接测试,我们验证组件支持清除功能
@@ -16,12 +16,30 @@ export interface FlowContextSelectorProps
16
16
  value?: string;
17
17
  onChange?: (value: string, metaTreeNode?: MetaTreeNode) => void;
18
18
  children?: CascaderProps<ContextSelectorItem>['children'];
19
+ /**
20
+ * Controls whether the default `x` trigger button is rendered as active
21
+ * (`type="primary"`). When omitted, the selector falls back to its parsed
22
+ * `value` path (`true` iff a valid variable path is currently selected).
23
+ *
24
+ * Use this when callers intentionally feed synthetic paths such as
25
+ * `['constant']` / `['null']` into the cascader to keep menu state aligned,
26
+ * but only want real variable references to show the blue active button.
27
+ */
28
+ active?: boolean;
19
29
  metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
20
30
  parseValueToPath?: (value: string) => string[] | undefined;
21
31
  formatPathToValue?: (item: MetaTreeNode) => string;
22
32
  open?: boolean;
23
33
  onlyLeafSelectable?: boolean;
24
34
  ignoreFieldNames?: string[];
35
+ /**
36
+ * Footer rendered at the bottom of the dropdown. Defaults to a muted
37
+ * "Double click to choose entire object" hint when non-leaf selection is
38
+ * allowed (`onlyLeafSelectable` is false) — since double-clicking a non-leaf
39
+ * node selects the whole object. Pass an explicit node to override, or `null`
40
+ * to hide it.
41
+ */
42
+ dropdownFooter?: React.ReactNode;
25
43
  }
26
44
 
27
45
  export interface ContextSelectorItem {