@nocobase/flow-engine 2.2.0-alpha.7 → 2.2.0-alpha.9
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/components/variables/VariableInput.js +17 -7
- package/lib/components/variables/VariableTag.js +48 -36
- package/lib/components/variables/types.d.ts +3 -0
- package/lib/types.d.ts +3 -1
- package/lib/types.js +1 -0
- package/lib/utils/dirtyAwareApiClient.js +267 -13
- package/package.json +4 -4
- package/src/components/variables/VariableInput.tsx +30 -7
- package/src/components/variables/VariableTag.tsx +51 -37
- package/src/components/variables/__tests__/VariableInput.test.tsx +139 -1
- package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
- package/src/components/variables/types.ts +3 -0
- package/src/types.ts +2 -0
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +321 -0
- package/src/utils/dirtyAwareApiClient.ts +325 -13
|
@@ -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(() => {
|
|
@@ -197,9 +200,25 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
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,7 +379,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
356
379
|
<FlowContextSelector
|
|
357
380
|
metaTree={resolvedMetaTree}
|
|
358
381
|
value={innerValue}
|
|
359
|
-
active={
|
|
382
|
+
active={isVariableActive}
|
|
360
383
|
disabled={disabled}
|
|
361
384
|
onChange={handleVariableSelect}
|
|
362
385
|
parseValueToPath={resolvePathFromValue}
|
|
@@ -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}
|
|
@@ -101,6 +101,127 @@ describe('VariableInput', () => {
|
|
|
101
101
|
);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
it('renders a variable tag when custom converters resolve a non-ctx workflow variable format', async () => {
|
|
105
|
+
const flowContext = createTestFlowContext();
|
|
106
|
+
const workflowMetaTree = [
|
|
107
|
+
{
|
|
108
|
+
name: '$context',
|
|
109
|
+
title: 'Trigger variables',
|
|
110
|
+
type: 'object',
|
|
111
|
+
paths: ['$context'],
|
|
112
|
+
children: [
|
|
113
|
+
{
|
|
114
|
+
name: 'data',
|
|
115
|
+
title: 'Trigger data',
|
|
116
|
+
type: 'object',
|
|
117
|
+
paths: ['$context', 'data'],
|
|
118
|
+
children: [
|
|
119
|
+
{
|
|
120
|
+
name: 'updatedAt',
|
|
121
|
+
title: 'Last updated at',
|
|
122
|
+
type: 'string',
|
|
123
|
+
paths: ['$context', 'data', 'updatedAt'],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
render(
|
|
132
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
133
|
+
<VariableInput
|
|
134
|
+
value="{{$context.data.updatedAt}}"
|
|
135
|
+
metaTree={workflowMetaTree}
|
|
136
|
+
converters={{
|
|
137
|
+
resolvePathFromValue: (currentValue) =>
|
|
138
|
+
currentValue === '{{$context.data.updatedAt}}' ? ['$context', 'data', 'updatedAt'] : undefined,
|
|
139
|
+
resolveValueFromPath: () => undefined,
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
</TestFlowContextWrapper>,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
await waitFor(
|
|
146
|
+
() => {
|
|
147
|
+
const variableTag = screen.getByText('Trigger variables/Trigger data/Last updated at');
|
|
148
|
+
expect(variableTag).toBeInTheDocument();
|
|
149
|
+
expect(variableTag.closest('.ant-tag')).toBeInTheDocument();
|
|
150
|
+
},
|
|
151
|
+
{ timeout: 3000 },
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const selectorButton = screen.getByRole('button');
|
|
155
|
+
expect(selectorButton.className).toContain('ant-btn-primary');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('renders a parsing failure tag when a custom variable path is missing from the meta tree', async () => {
|
|
159
|
+
const flowContext = createTestFlowContext();
|
|
160
|
+
const workflowMetaTree = [
|
|
161
|
+
{
|
|
162
|
+
name: '$context',
|
|
163
|
+
title: 'Trigger variables',
|
|
164
|
+
type: 'object',
|
|
165
|
+
paths: ['$context'],
|
|
166
|
+
children: [
|
|
167
|
+
{
|
|
168
|
+
name: 'data',
|
|
169
|
+
title: 'Trigger data',
|
|
170
|
+
type: 'object',
|
|
171
|
+
paths: ['$context', 'data'],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
render(
|
|
178
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
179
|
+
<VariableInput
|
|
180
|
+
value="{{$context.data.id}}"
|
|
181
|
+
metaTree={workflowMetaTree}
|
|
182
|
+
converters={{
|
|
183
|
+
resolvePathFromValue: (currentValue) =>
|
|
184
|
+
currentValue === '{{$context.data.id}}' ? ['$context', 'data', 'id'] : undefined,
|
|
185
|
+
resolveValueFromPath: () => undefined,
|
|
186
|
+
}}
|
|
187
|
+
/>
|
|
188
|
+
</TestFlowContextWrapper>,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
await waitFor(
|
|
192
|
+
() => {
|
|
193
|
+
const variableTag = screen.getByText('Variable parsing failed');
|
|
194
|
+
expect(variableTag).toBeInTheDocument();
|
|
195
|
+
expect(variableTag.closest('.ant-tag')).toHaveClass('ant-tag-error');
|
|
196
|
+
},
|
|
197
|
+
{ timeout: 3000 },
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(screen.queryByDisplayValue('{{$context.data.id}}')).not.toBeInTheDocument();
|
|
201
|
+
expect(screen.getByRole('button').className).toContain('ant-btn-primary');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('disables custom tag input when rendering a selected variable', async () => {
|
|
205
|
+
const flowContext = createTestFlowContext();
|
|
206
|
+
const { container } = render(
|
|
207
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
208
|
+
<VariableInput value="{{ ctx.user.name }}" metaTree={() => flowContext.getPropertyMetaTree()} />
|
|
209
|
+
</TestFlowContextWrapper>,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
await waitFor(
|
|
213
|
+
() => {
|
|
214
|
+
expect(screen.getByText('User/Name')).toBeInTheDocument();
|
|
215
|
+
},
|
|
216
|
+
{ timeout: 3000 },
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const selectElement = container.querySelector('.ant-select.variable');
|
|
220
|
+
expect(selectElement).toBeInTheDocument();
|
|
221
|
+
expect(selectElement).not.toHaveClass('ant-select-show-search');
|
|
222
|
+
expect(container.querySelector('.ant-select-selection-search-input')).toHaveAttribute('readonly');
|
|
223
|
+
});
|
|
224
|
+
|
|
104
225
|
it('should render FlowContextSelector button', async () => {
|
|
105
226
|
const flowContext = createTestFlowContext();
|
|
106
227
|
render(
|
|
@@ -334,7 +455,24 @@ describe('VariableInput', () => {
|
|
|
334
455
|
expect(input).toHaveClass('custom-class');
|
|
335
456
|
|
|
336
457
|
// Note: The disabled prop might not be correctly passed through in the current implementation
|
|
337
|
-
|
|
458
|
+
expect(input).toBeDisabled();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('disables the rendered variable tag when the input is disabled', async () => {
|
|
462
|
+
const flowContext = createTestFlowContext();
|
|
463
|
+
const { container } = render(
|
|
464
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
465
|
+
<VariableInput value="{{ ctx.user.name }}" metaTree={() => flowContext.getPropertyMetaTree()} disabled />
|
|
466
|
+
</TestFlowContextWrapper>,
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
await waitFor(() => {
|
|
470
|
+
expect(screen.getByText('User/Name')).toBeInTheDocument();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const selectElement = container.querySelector('.ant-select.variable');
|
|
474
|
+
expect(selectElement).toHaveClass('ant-select-disabled');
|
|
475
|
+
expect(container.querySelector('.ant-select-clear')).not.toBeInTheDocument();
|
|
338
476
|
});
|
|
339
477
|
|
|
340
478
|
it('should handle empty metaTree', async () => {
|
|
@@ -54,6 +54,44 @@ describe('VariableTag', () => {
|
|
|
54
54
|
expect(onClear).toBeInstanceOf(Function);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
it('keeps custom tag input enabled by default', async () => {
|
|
58
|
+
const onClear = vi.fn();
|
|
59
|
+
const { container } = renderWithCtx(<VariableTag value="{{ ctx.User.Email }}" onClear={onClear} />);
|
|
60
|
+
|
|
61
|
+
await waitFor(
|
|
62
|
+
() => {
|
|
63
|
+
expect(screen.getByText('User/Email')).toBeInTheDocument();
|
|
64
|
+
},
|
|
65
|
+
{ timeout: 3000 },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const selectElement = container.querySelector('.ant-select.variable');
|
|
69
|
+
expect(selectElement).toBeInTheDocument();
|
|
70
|
+
expect(selectElement).toHaveClass('ant-select-show-search');
|
|
71
|
+
expect(container.querySelector('.ant-select-selection-search-input')).not.toHaveAttribute('readonly');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('can disable custom tag input while keeping clear enabled', async () => {
|
|
75
|
+
const onClear = vi.fn();
|
|
76
|
+
const { container } = renderWithCtx(
|
|
77
|
+
<VariableTag value="{{ ctx.User.Email }}" onClear={onClear} allowCustomTagInput={false} />,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await waitFor(
|
|
81
|
+
() => {
|
|
82
|
+
expect(screen.getByText('User/Email')).toBeInTheDocument();
|
|
83
|
+
},
|
|
84
|
+
{ timeout: 3000 },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const selectElement = container.querySelector('.ant-select.variable');
|
|
88
|
+
expect(selectElement).toBeInTheDocument();
|
|
89
|
+
expect(selectElement).not.toHaveClass('ant-select-disabled');
|
|
90
|
+
expect(selectElement).not.toHaveClass('ant-select-show-search');
|
|
91
|
+
expect(container.querySelector('.ant-select-clear')).toBeInTheDocument();
|
|
92
|
+
expect(container.querySelector('.ant-select-selection-search-input')).toHaveAttribute('readonly');
|
|
93
|
+
});
|
|
94
|
+
|
|
57
95
|
it('should not show close button when onClear is not provided', async () => {
|
|
58
96
|
const { container } = renderWithCtx(<VariableTag value="{{ ctx.User.Name }}" />);
|
|
59
97
|
|
|
@@ -166,6 +204,32 @@ describe('VariableTag', () => {
|
|
|
166
204
|
}
|
|
167
205
|
});
|
|
168
206
|
|
|
207
|
+
it('renders a red failure pill when the variable path cannot be resolved', async () => {
|
|
208
|
+
renderWithCtx(
|
|
209
|
+
<VariableTag
|
|
210
|
+
value="{{ ctx.missing.field }}"
|
|
211
|
+
metaTree={[
|
|
212
|
+
{
|
|
213
|
+
name: 'user',
|
|
214
|
+
title: 'User',
|
|
215
|
+
type: 'object',
|
|
216
|
+
paths: ['user'],
|
|
217
|
+
children: [{ name: 'name', title: 'Name', type: 'string', paths: ['user', 'name'] }],
|
|
218
|
+
},
|
|
219
|
+
]}
|
|
220
|
+
/>,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
await waitFor(
|
|
224
|
+
() => {
|
|
225
|
+
const tag = screen.getByText('Variable parsing failed');
|
|
226
|
+
expect(tag).toBeInTheDocument();
|
|
227
|
+
expect(tag.closest('.ant-tag')).toHaveClass('ant-tag-error');
|
|
228
|
+
},
|
|
229
|
+
{ timeout: 3000 },
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
169
233
|
it('should render Select component with proper structure', async () => {
|
|
170
234
|
const { container } = render(<VariableTag value="{{ ctx.Test }}" />);
|
|
171
235
|
|
|
@@ -233,6 +297,22 @@ describe('VariableTag', () => {
|
|
|
233
297
|
expect(selectElement).not.toHaveClass('ant-select-disabled');
|
|
234
298
|
});
|
|
235
299
|
|
|
300
|
+
it('does not show clear affordance when disabled even if onClear is provided', async () => {
|
|
301
|
+
const onClear = vi.fn();
|
|
302
|
+
const { container } = renderWithCtx(<VariableTag value="{{ ctx.Test }}" onClear={onClear} disabled />);
|
|
303
|
+
|
|
304
|
+
await waitFor(
|
|
305
|
+
() => {
|
|
306
|
+
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
307
|
+
},
|
|
308
|
+
{ timeout: 3000 },
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const selectElement = container.querySelector('.ant-select.variable');
|
|
312
|
+
expect(selectElement).toHaveClass('ant-select-disabled');
|
|
313
|
+
expect(container.querySelector('.ant-select-clear')).not.toBeInTheDocument();
|
|
314
|
+
});
|
|
315
|
+
|
|
236
316
|
it('should have proper accessibility attributes for Select component', async () => {
|
|
237
317
|
const { container } = renderWithCtx(<VariableTag value="{{ ctx.Test }}" />);
|
|
238
318
|
|
|
@@ -94,7 +94,10 @@ export interface VariableInputProps {
|
|
|
94
94
|
|
|
95
95
|
export interface VariableTagProps {
|
|
96
96
|
value?: string;
|
|
97
|
+
resolvedPath?: Array<string | number>;
|
|
97
98
|
onClear?: () => void;
|
|
99
|
+
disabled?: boolean;
|
|
100
|
+
allowCustomTagInput?: boolean;
|
|
98
101
|
className?: string;
|
|
99
102
|
style?: React.CSSProperties;
|
|
100
103
|
metaTreeNode?: MetaTreeNode | null;
|