@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.
- package/lib/JSRunner.d.ts +1 -0
- package/lib/JSRunner.js +110 -20
- package/lib/components/FieldModelRenderer.js +44 -31
- package/lib/components/FlowContextSelector.js +24 -3
- package/lib/components/dnd/index.js +9 -2
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
- package/lib/components/variables/VariableHybridInput.d.ts +8 -0
- package/lib/components/variables/VariableHybridInput.js +128 -12
- package/lib/components/variables/types.d.ts +8 -0
- package/lib/flowContext.d.ts +1 -1
- package/lib/flowContext.js +87 -36
- package/lib/flowI18n.js +3 -3
- package/lib/flowSettings.d.ts +5 -1
- package/lib/flowSettings.js +70 -0
- package/lib/locale/en-US.json +1 -0
- package/lib/locale/index.d.ts +2 -0
- package/lib/locale/zh-CN.json +1 -0
- package/lib/resources/apiResource.js +2 -1
- package/lib/resources/baseRecordResource.js +6 -17
- package/lib/resources/multiRecordResource.js +13 -3
- package/lib/resources/singleRecordResource.js +7 -2
- package/lib/runjs-context/helpers.js +12 -5
- package/lib/types.d.ts +12 -0
- package/lib/utils/dataSourceDirty.d.ts +20 -0
- package/lib/utils/dataSourceDirty.js +139 -0
- package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
- package/lib/utils/dirtyAwareApiClient.js +378 -0
- package/lib/utils/index.d.ts +1 -1
- package/lib/utils/index.js +11 -11
- package/lib/utils/openViewRouteState.d.ts +28 -0
- package/lib/utils/openViewRouteState.js +125 -0
- package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
- package/lib/utils/parsePathnameToViewParams.js +18 -1
- package/lib/utils/resolveRunJSObjectValues.js +3 -2
- package/lib/utils/runjsModuleLoader.js +0 -30
- package/lib/views/ViewNavigation.js +5 -0
- package/package.json +4 -4
- package/src/JSRunner.ts +112 -25
- package/src/__tests__/JSRunner.test.ts +4 -5
- package/src/__tests__/flowContext.test.ts +131 -0
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
- package/src/__tests__/flowSettings.test.ts +72 -0
- package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
- package/src/components/FieldModelRenderer.tsx +50 -36
- package/src/components/FlowContextSelector.tsx +33 -2
- package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
- package/src/components/dnd/index.tsx +11 -2
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
- package/src/components/variables/VariableHybridInput.tsx +166 -9
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +178 -0
- package/src/components/variables/types.ts +8 -0
- package/src/flowContext.ts +100 -33
- package/src/flowI18n.ts +8 -3
- package/src/flowSettings.ts +85 -1
- package/src/locale/en-US.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/resources/apiResource.ts +2 -1
- package/src/resources/baseRecordResource.ts +6 -23
- package/src/resources/multiRecordResource.ts +13 -3
- package/src/resources/singleRecordResource.ts +6 -1
- package/src/runjs-context/helpers.ts +12 -6
- package/src/types.ts +14 -0
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
- package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
- package/src/utils/dataSourceDirty.ts +126 -0
- package/src/utils/dirtyAwareApiClient.ts +430 -0
- package/src/utils/index.ts +10 -9
- package/src/utils/openViewRouteState.ts +107 -0
- package/src/utils/parsePathnameToViewParams.ts +23 -1
- package/src/utils/resolveRunJSObjectValues.ts +5 -2
- package/src/utils/runjsModuleLoader.ts +0 -32
- package/src/views/ViewNavigation.ts +6 -1
- package/src/views/__tests__/ViewNavigation.test.ts +15 -0
- package/lib/utils/safeGlobals.d.ts +0 -28
- package/lib/utils/safeGlobals.js +0 -367
- package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
- package/src/utils/__tests__/safeGlobals.test.ts +0 -106
- 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
|
|
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 {
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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={
|
|
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 {
|