@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.
- package/lib/acl/Acl.d.ts +2 -1
- package/lib/acl/Acl.js +28 -0
- package/lib/components/FlowContextSelector.js +55 -12
- package/lib/components/FormItem.js +11 -7
- package/lib/components/MobilePopup.js +14 -3
- package/lib/components/subModel/LazyDropdown.js +41 -26
- package/lib/components/variables/VariableHybridInput.d.ts +9 -0
- package/lib/components/variables/VariableHybridInput.js +146 -17
- package/lib/components/variables/VariableInput.js +19 -7
- package/lib/components/variables/VariableTag.js +48 -36
- package/lib/components/variables/types.d.ts +21 -0
- package/lib/flowI18n.js +3 -3
- package/lib/locale/en-US.json +2 -0
- package/lib/locale/index.d.ts +4 -0
- package/lib/locale/zh-CN.json +2 -0
- package/lib/types.d.ts +3 -1
- package/lib/types.js +1 -0
- package/package.json +4 -4
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/acl/Acl.tsx +36 -1
- package/src/acl/__tests__/Acl.test.tsx +70 -0
- package/src/components/FlowContextSelector.tsx +66 -11
- package/src/components/FormItem.tsx +12 -7
- package/src/components/MobilePopup.tsx +16 -4
- package/src/components/__tests__/FormItem.test.tsx +17 -2
- package/src/components/__tests__/MobilePopup.test.tsx +42 -1
- package/src/components/subModel/LazyDropdown.tsx +44 -26
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- package/src/components/variables/VariableHybridInput.tsx +185 -14
- package/src/components/variables/VariableInput.tsx +32 -7
- package/src/components/variables/VariableTag.tsx +51 -37
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +60 -3
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
- package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
- package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
- package/src/components/variables/types.ts +21 -0
- package/src/flowI18n.ts +8 -3
- package/src/locale/__tests__/index.test.ts +21 -0
- package/src/locale/en-US.json +2 -0
- package/src/locale/zh-CN.json +2 -0
- package/src/types.ts +2 -0
|
@@ -125,16 +125,19 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
125
125
|
[onChange],
|
|
126
126
|
);
|
|
127
127
|
|
|
128
|
+
const resolvedPath = useMemo(() => {
|
|
129
|
+
return resolvePathFromValue?.(innerValue);
|
|
130
|
+
}, [innerValue, resolvePathFromValue]);
|
|
131
|
+
|
|
128
132
|
const resolvedMetaTreeNode = useMemo(() => {
|
|
129
133
|
if (currentMetaTreeNode) return currentMetaTreeNode;
|
|
130
134
|
if (Array.isArray(resolvedMetaTree)) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return findMetaTreeNodeByPath(resolvedMetaTree, path);
|
|
135
|
+
if (resolvedPath) {
|
|
136
|
+
return findMetaTreeNodeByPath(resolvedMetaTree, resolvedPath);
|
|
134
137
|
}
|
|
135
138
|
}
|
|
136
139
|
return null;
|
|
137
|
-
}, [currentMetaTreeNode,
|
|
140
|
+
}, [currentMetaTreeNode, resolvedMetaTree, resolvedPath]);
|
|
138
141
|
|
|
139
142
|
// 当 value 存在但 currentMetaTreeNode 还未恢复,尝试按路径逐级加载(支持 children 为函数的场景)
|
|
140
143
|
useEffect(() => {
|
|
@@ -192,14 +195,30 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
192
195
|
};
|
|
193
196
|
|
|
194
197
|
restoreFromValue();
|
|
195
|
-
}, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode]);
|
|
198
|
+
}, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode, value]);
|
|
196
199
|
|
|
197
200
|
const ValueComponent = useMemo(() => {
|
|
198
201
|
const Component = renderInputComponent?.(resolvedMetaTreeNode);
|
|
199
202
|
const CustomComponent = resolvedMetaTreeNode?.render;
|
|
200
|
-
|
|
203
|
+
// Some domains (workflow) persist variables as `{{$context...}}` rather than the core `{{ ctx... }}` form.
|
|
204
|
+
// When a custom converter recognizes such a value as a variable path, render VariableTag even if the path no longer
|
|
205
|
+
// exists in the current meta tree. VariableTag will then show either the resolved label or the parsing-failed state.
|
|
206
|
+
const shouldRenderVariableTag =
|
|
207
|
+
isVariableValue(innerValue) ||
|
|
208
|
+
(Array.isArray(resolvedPath) && resolvedPath.length > 0 && !Component && !CustomComponent);
|
|
209
|
+
const finalComponent = shouldRenderVariableTag ? VariableTag : Component || CustomComponent || Input;
|
|
201
210
|
return finalComponent;
|
|
202
|
-
}, [renderInputComponent, resolvedMetaTreeNode, innerValue]);
|
|
211
|
+
}, [renderInputComponent, resolvedMetaTreeNode, innerValue, resolvedPath]);
|
|
212
|
+
|
|
213
|
+
const isVariableActive = useMemo(() => {
|
|
214
|
+
return (
|
|
215
|
+
isVariableValue(innerValue) ||
|
|
216
|
+
(Array.isArray(resolvedPath) &&
|
|
217
|
+
resolvedPath.length > 0 &&
|
|
218
|
+
!renderInputComponent?.(resolvedMetaTreeNode) &&
|
|
219
|
+
!resolvedMetaTreeNode?.render)
|
|
220
|
+
);
|
|
221
|
+
}, [innerValue, renderInputComponent, resolvedMetaTreeNode, resolvedPath]);
|
|
203
222
|
|
|
204
223
|
useEffect(() => {
|
|
205
224
|
if (!resolvedMetaTreeNode) return;
|
|
@@ -303,8 +322,11 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
303
322
|
return {
|
|
304
323
|
...baseProps,
|
|
305
324
|
onClear: handleClear,
|
|
325
|
+
disabled,
|
|
326
|
+
allowCustomTagInput: false,
|
|
306
327
|
metaTreeNode: resolvedMetaTreeNode,
|
|
307
328
|
metaTree,
|
|
329
|
+
resolvedPath,
|
|
308
330
|
style: stableProps.style,
|
|
309
331
|
};
|
|
310
332
|
}
|
|
@@ -327,6 +349,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
327
349
|
disabled,
|
|
328
350
|
handleClear,
|
|
329
351
|
resolvedMetaTreeNode,
|
|
352
|
+
resolvedPath,
|
|
330
353
|
metaTree,
|
|
331
354
|
ValueComponent,
|
|
332
355
|
stableProps,
|
|
@@ -356,6 +379,8 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
356
379
|
<FlowContextSelector
|
|
357
380
|
metaTree={resolvedMetaTree}
|
|
358
381
|
value={innerValue}
|
|
382
|
+
active={isVariableActive}
|
|
383
|
+
disabled={disabled}
|
|
359
384
|
onChange={handleVariableSelect}
|
|
360
385
|
parseValueToPath={resolvePathFromValue}
|
|
361
386
|
formatPathToValue={resolveValueFromPath}
|
|
@@ -17,41 +17,46 @@ import { useRequest } from 'ahooks';
|
|
|
17
17
|
import { useFlowContext } from '../../FlowContextProvider';
|
|
18
18
|
import type { MetaTreeNode } from '../../flowContext';
|
|
19
19
|
|
|
20
|
+
const VARIABLE_PARSING_FAILED_TEXT = 'Variable parsing failed';
|
|
21
|
+
|
|
20
22
|
const VariableTagComponent: React.FC<VariableTagProps> = ({
|
|
21
23
|
value,
|
|
22
24
|
onClear,
|
|
25
|
+
disabled = false,
|
|
26
|
+
allowCustomTagInput = true,
|
|
23
27
|
className,
|
|
24
28
|
style,
|
|
25
29
|
metaTreeNode,
|
|
26
30
|
metaTree,
|
|
31
|
+
resolvedPath,
|
|
27
32
|
}) => {
|
|
28
33
|
const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
|
|
29
34
|
const ctx = useFlowContext();
|
|
30
35
|
|
|
31
|
-
const { data:
|
|
36
|
+
const { data: displayState } = useRequest(
|
|
32
37
|
async () => {
|
|
33
|
-
const resolveLabelFromPath = async (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (!
|
|
38
|
+
const resolveLabelFromPath = async (
|
|
39
|
+
rawPath?: (string | number)[],
|
|
40
|
+
): Promise<{ label: string | null; resolved: boolean; attempted: boolean }> => {
|
|
41
|
+
if (!rawPath) return { label: null, resolved: false, attempted: false };
|
|
42
|
+
if (!Array.isArray(rawPath)) return { label: null, resolved: false, attempted: false };
|
|
43
|
+
if (!Array.isArray(resolvedMetaTree)) return { label: null, resolved: false, attempted: false };
|
|
37
44
|
|
|
38
45
|
// 兼容 metaTree 为子树:顶层不含首段时,裁剪首段
|
|
39
46
|
const topNames = new Set((resolvedMetaTree || []).map((n: any) => String(n?.name)));
|
|
40
47
|
const path = !topNames.has(String(rawPath[0])) ? rawPath.slice(1) : rawPath;
|
|
41
|
-
if (!path.length) return '';
|
|
48
|
+
if (!path.length) return { label: '', resolved: true, attempted: true };
|
|
42
49
|
|
|
43
50
|
let nodes: MetaTreeNode[] | undefined = resolvedMetaTree as MetaTreeNode[];
|
|
44
51
|
const titleChain: string[] = [];
|
|
45
|
-
let matchedCount = 0;
|
|
46
52
|
|
|
47
53
|
for (let i = 0; i < path.length; i++) {
|
|
48
|
-
if (!nodes)
|
|
54
|
+
if (!nodes) return { label: null, resolved: false, attempted: true };
|
|
49
55
|
const seg = String(path[i]);
|
|
50
56
|
const node = nodes.find((n) => String(n?.name) === seg) as MetaTreeNode | undefined;
|
|
51
|
-
if (!node)
|
|
57
|
+
if (!node) return { label: null, resolved: false, attempted: true };
|
|
52
58
|
|
|
53
59
|
titleChain.push(String(node.title ?? node.name ?? seg));
|
|
54
|
-
matchedCount = i + 1;
|
|
55
60
|
|
|
56
61
|
if (i < path.length - 1) {
|
|
57
62
|
if (Array.isArray(node.children)) {
|
|
@@ -70,36 +75,43 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
|
|
|
70
75
|
}
|
|
71
76
|
}
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
let label = titleChain.map(ctx.t).join('/');
|
|
76
|
-
if (matchedCount < path.length) {
|
|
77
|
-
const tail = path.slice(matchedCount).join('/');
|
|
78
|
-
label = tail ? `${label}/${tail}` : label;
|
|
79
|
-
}
|
|
80
|
-
return label;
|
|
78
|
+
return { label: titleChain.map(ctx.t).join('/'), resolved: true, attempted: true };
|
|
81
79
|
};
|
|
82
80
|
|
|
83
81
|
// 1) 优先使用已解析到的节点(包含完整父标题链)
|
|
84
82
|
if (metaTreeNode?.parentTitles) {
|
|
85
|
-
return
|
|
83
|
+
return {
|
|
84
|
+
text: [...metaTreeNode.parentTitles, metaTreeNode.title].map(ctx.t).join('/'),
|
|
85
|
+
invalid: false,
|
|
86
|
+
};
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
// 2) metaTreeNode 存在但缺少 parentTitles:尝试根据 value/metaTreeNode.paths 从 metaTree 还原完整路径
|
|
89
90
|
if (metaTreeNode) {
|
|
90
|
-
const rawPath = parseValueToPath(value) || metaTreeNode.paths;
|
|
91
|
-
const
|
|
92
|
-
|
|
91
|
+
const rawPath = resolvedPath || parseValueToPath(value) || metaTreeNode.paths;
|
|
92
|
+
const result = await resolveLabelFromPath(rawPath as any);
|
|
93
|
+
if (result.resolved && result.label != null) {
|
|
94
|
+
return { text: result.label, invalid: false };
|
|
95
|
+
}
|
|
96
|
+
if (result.attempted) {
|
|
97
|
+
return { text: VARIABLE_PARSING_FAILED_TEXT, invalid: true, rawValue: String(value ?? '') };
|
|
98
|
+
}
|
|
99
|
+
return { text: ctx.t(metaTreeNode.title) ?? '', invalid: false };
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
// 3) 无 metaTreeNode:从 value 还原路径并拼接标题链;若找不到任何前缀则回退原始路径字符串
|
|
96
|
-
if (!value) return String(value);
|
|
97
|
-
const rawPath = parseValueToPath(value);
|
|
98
|
-
const
|
|
99
|
-
if (label != null)
|
|
100
|
-
|
|
103
|
+
if (!value) return { text: String(value), invalid: false };
|
|
104
|
+
const rawPath = resolvedPath || parseValueToPath(value);
|
|
105
|
+
const result = await resolveLabelFromPath(rawPath as any);
|
|
106
|
+
if (result.resolved && result.label != null) {
|
|
107
|
+
return { text: result.label, invalid: false };
|
|
108
|
+
}
|
|
109
|
+
if (result.attempted) {
|
|
110
|
+
return { text: VARIABLE_PARSING_FAILED_TEXT, invalid: true, rawValue: String(value ?? '') };
|
|
111
|
+
}
|
|
112
|
+
return { text: Array.isArray(rawPath) ? rawPath.join('/') : String(value), invalid: false };
|
|
101
113
|
},
|
|
102
|
-
{ refreshDeps: [resolvedMetaTree, value, metaTreeNode] },
|
|
114
|
+
{ refreshDeps: [resolvedMetaTree, resolvedPath, value, metaTreeNode] },
|
|
103
115
|
);
|
|
104
116
|
|
|
105
117
|
const { token } = theme.useToken();
|
|
@@ -119,13 +131,13 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
|
|
|
119
131
|
`;
|
|
120
132
|
|
|
121
133
|
const customTagRender = (props: any) => {
|
|
122
|
-
const
|
|
123
|
-
const
|
|
134
|
+
const fullText = displayState?.text || (typeof props.label === 'string' ? props.label : String(props.label));
|
|
135
|
+
const tooltipText = displayState?.invalid ? displayState.rawValue || fullText : fullText;
|
|
124
136
|
|
|
125
137
|
return (
|
|
126
|
-
<Tooltip title={
|
|
138
|
+
<Tooltip title={tooltipText} placement="top" getPopupContainer={() => document.body}>
|
|
127
139
|
<Tag
|
|
128
|
-
color=
|
|
140
|
+
color={displayState?.invalid ? 'error' : 'blue'}
|
|
129
141
|
style={{
|
|
130
142
|
margin: `0 ${token.marginXXS || token.marginXS}px`,
|
|
131
143
|
borderRadius: token.borderRadiusSM,
|
|
@@ -166,12 +178,14 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
|
|
|
166
178
|
flex: '1 1 auto',
|
|
167
179
|
...style,
|
|
168
180
|
}}
|
|
169
|
-
value={
|
|
170
|
-
mode=
|
|
181
|
+
value={displayState?.text ? [displayState.text] : []}
|
|
182
|
+
mode={allowCustomTagInput ? 'tags' : 'multiple'}
|
|
171
183
|
open={false}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
184
|
+
showSearch={allowCustomTagInput}
|
|
185
|
+
searchValue={allowCustomTagInput ? undefined : ''}
|
|
186
|
+
allowClear={!disabled && !!onClear}
|
|
187
|
+
onClear={disabled ? undefined : onClear}
|
|
188
|
+
disabled={disabled || !onClear}
|
|
175
189
|
variant="outlined"
|
|
176
190
|
suffixIcon={null}
|
|
177
191
|
tagRender={customTagRender}
|
|
@@ -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());
|
|
@@ -267,6 +284,43 @@ describe('FlowContextSelector', () => {
|
|
|
267
284
|
});
|
|
268
285
|
});
|
|
269
286
|
|
|
287
|
+
it('shows enabled variable item tooltip above the menu item', async () => {
|
|
288
|
+
const flowContext = createTestFlowContext();
|
|
289
|
+
const metaTree: MetaTreeNode[] = [
|
|
290
|
+
{
|
|
291
|
+
name: '$system',
|
|
292
|
+
title: 'System variables',
|
|
293
|
+
type: '',
|
|
294
|
+
paths: ['$system'],
|
|
295
|
+
children: [
|
|
296
|
+
{
|
|
297
|
+
name: 'instanceId',
|
|
298
|
+
title: 'Instance ID',
|
|
299
|
+
type: '',
|
|
300
|
+
paths: ['$system', 'instanceId'],
|
|
301
|
+
options: { tooltip: 'The ID of current server instance' },
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
},
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
render(
|
|
308
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
309
|
+
<FlowContextSelector metaTree={metaTree} />
|
|
310
|
+
</TestFlowContextWrapper>,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
fireEvent.click(screen.getByRole('button'));
|
|
314
|
+
fireEvent.click(await screen.findByText('System variables'));
|
|
315
|
+
|
|
316
|
+
expect(await screen.findByText('Instance ID')).toBeInTheDocument();
|
|
317
|
+
const tooltipIcon = screen.getByLabelText('Instance ID tooltip');
|
|
318
|
+
fireEvent.mouseEnter(tooltipIcon);
|
|
319
|
+
|
|
320
|
+
const tooltip = await screen.findByText('The ID of current server instance');
|
|
321
|
+
expect(tooltip.closest('.ant-tooltip')).toHaveClass('ant-tooltip-placement-top');
|
|
322
|
+
});
|
|
323
|
+
|
|
270
324
|
it('should support inline search input when children is null and keep lazy expand', async () => {
|
|
271
325
|
const flowContext = createTestFlowContext();
|
|
272
326
|
const loadOrgChildren = vi.fn(async () => [
|
|
@@ -405,10 +459,13 @@ describe('FlowContextSelector', () => {
|
|
|
405
459
|
|
|
406
460
|
const clearIcon = document.querySelector('.ant-input-clear-icon') as HTMLElement | null;
|
|
407
461
|
expect(clearIcon).toBeInTheDocument();
|
|
462
|
+
if (!clearIcon) {
|
|
463
|
+
throw new Error('Expected inline clear icon to be present');
|
|
464
|
+
}
|
|
408
465
|
|
|
409
|
-
fireEvent.mouseDown(clearIcon
|
|
410
|
-
fireEvent.mouseUp(clearIcon
|
|
411
|
-
fireEvent.click(clearIcon
|
|
466
|
+
fireEvent.mouseDown(clearIcon);
|
|
467
|
+
fireEvent.mouseUp(clearIcon);
|
|
468
|
+
fireEvent.click(clearIcon);
|
|
412
469
|
|
|
413
470
|
await waitFor(() => {
|
|
414
471
|
expect(onChange).toHaveBeenCalledWith('', undefined);
|
|
@@ -0,0 +1,212 @@
|
|
|
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 { fireEvent, render, screen, 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
|
+
|
|
179
|
+
it('keeps the selector enabled when only the editor is read-only', async () => {
|
|
180
|
+
const flowContext = createTestFlowContext();
|
|
181
|
+
const onChange = vi.fn();
|
|
182
|
+
const metaTree: MetaTreeNode[] = [
|
|
183
|
+
{
|
|
184
|
+
name: '$user',
|
|
185
|
+
title: 'User',
|
|
186
|
+
type: '',
|
|
187
|
+
paths: ['$user'],
|
|
188
|
+
children: [{ name: 'name', title: 'Name', type: 'string', paths: ['$user', 'name'] }],
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
render(
|
|
193
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
194
|
+
<VariableHybridInput
|
|
195
|
+
readOnly
|
|
196
|
+
value=""
|
|
197
|
+
onChange={onChange}
|
|
198
|
+
metaTree={metaTree}
|
|
199
|
+
converters={workflowConverters}
|
|
200
|
+
/>
|
|
201
|
+
</TestFlowContextWrapper>,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const editor = screen.getByRole('textbox');
|
|
205
|
+
expect(editor).toHaveAttribute('contenteditable', 'false');
|
|
206
|
+
expect(editor).toHaveAttribute('aria-readonly', 'true');
|
|
207
|
+
|
|
208
|
+
fireEvent.input(editor, { currentTarget: { textContent: 'manual' } });
|
|
209
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
210
|
+
expect(screen.getByRole('button')).not.toBeDisabled();
|
|
211
|
+
});
|
|
212
|
+
});
|