@nocobase/flow-engine 2.2.0-beta.9 → 2.2.0
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 +62 -13
- package/lib/components/FormItem.js +11 -7
- package/lib/components/MobilePopup.js +39 -10
- package/lib/components/MobilePopup.style.js +11 -1
- package/lib/components/subModel/LazyDropdown.js +62 -33
- 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/flowContext.d.ts +12 -1
- package/lib/flowContext.js +47 -6
- package/lib/flowEngine.js +6 -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/resources/flowResource.js +1 -0
- package/lib/types.d.ts +3 -1
- package/lib/types.js +1 -0
- package/lib/utils/associationObjectVariable.d.ts +10 -0
- package/lib/utils/associationObjectVariable.js +10 -7
- package/lib/utils/dateVariable.d.ts +22 -0
- package/lib/utils/dateVariable.js +123 -16
- package/lib/utils/dirtyAwareApiClient.d.ts +1 -0
- package/lib/utils/dirtyAwareApiClient.js +280 -13
- package/lib/utils/index.d.ts +3 -3
- package/lib/utils/index.js +8 -0
- package/lib/utils/loadedPageCache.d.ts +1 -0
- package/lib/utils/loadedPageCache.js +6 -0
- package/lib/utils/params-resolvers.d.ts +3 -0
- package/lib/utils/params-resolvers.js +10 -0
- package/lib/utils/variablesParams.js +5 -0
- package/lib/views/createViewMeta.d.ts +1 -0
- package/lib/views/createViewMeta.js +53 -22
- package/package.json +4 -4
- package/src/__tests__/createViewMeta.popup.test.ts +84 -1
- package/src/__tests__/flowContext.test.ts +8 -0
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/__tests__/objectVariable.test.ts +6 -1
- package/src/__tests__/runjsFormSubmit.test.ts +138 -0
- package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
- package/src/acl/Acl.tsx +36 -1
- package/src/acl/__tests__/Acl.test.tsx +70 -0
- package/src/components/FlowContextSelector.tsx +73 -12
- package/src/components/FormItem.tsx +12 -7
- package/src/components/MobilePopup.style.ts +12 -1
- package/src/components/MobilePopup.tsx +42 -10
- package/src/components/__tests__/FormItem.test.tsx +17 -2
- package/src/components/__tests__/MobilePopup.test.tsx +150 -0
- package/src/components/subModel/LazyDropdown.tsx +71 -38
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- package/src/components/subModel/__tests__/LazyDropdown.test.tsx +202 -0
- 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 +95 -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/flowContext.ts +79 -6
- package/src/flowEngine.ts +6 -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/resources/__tests__/flowResource.test.ts +3 -0
- package/src/resources/flowResource.ts +1 -0
- package/src/types.ts +2 -0
- package/src/utils/__tests__/dateVariable.test.ts +57 -4
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +321 -0
- package/src/utils/__tests__/variablesParams.test.ts +28 -1
- package/src/utils/associationObjectVariable.ts +9 -6
- package/src/utils/dateVariable.ts +145 -18
- package/src/utils/dirtyAwareApiClient.ts +348 -13
- package/src/utils/index.ts +17 -2
- package/src/utils/loadedPageCache.ts +7 -0
- package/src/utils/params-resolvers.ts +12 -0
- package/src/utils/variablesParams.ts +10 -0
- package/src/views/createViewMeta.ts +52 -18
|
@@ -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);
|
|
@@ -807,4 +864,39 @@ describe('FlowContextSelector', () => {
|
|
|
807
864
|
// It should only expand the node, not select it
|
|
808
865
|
expect(onChange).not.toHaveBeenCalled();
|
|
809
866
|
});
|
|
867
|
+
|
|
868
|
+
it('should expand but never select a node marked selectable=false', async () => {
|
|
869
|
+
const onChange = vi.fn();
|
|
870
|
+
const flowContext = createTestFlowContext();
|
|
871
|
+
const metaTree = [
|
|
872
|
+
{
|
|
873
|
+
name: 'date',
|
|
874
|
+
title: 'Date',
|
|
875
|
+
type: 'date',
|
|
876
|
+
paths: ['date'],
|
|
877
|
+
selectable: false,
|
|
878
|
+
children: [{ name: 'today', title: 'Today', type: 'date', paths: ['date', 'today'] }],
|
|
879
|
+
},
|
|
880
|
+
];
|
|
881
|
+
|
|
882
|
+
render(
|
|
883
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
884
|
+
<FlowContextSelector metaTree={metaTree} onChange={onChange} />
|
|
885
|
+
</TestFlowContextWrapper>,
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
fireEvent.click(screen.getByRole('button'));
|
|
889
|
+
await waitFor(() => expect(screen.getByText('Date')).toBeInTheDocument());
|
|
890
|
+
|
|
891
|
+
fireEvent.click(screen.getByText('Date'));
|
|
892
|
+
fireEvent.click(screen.getByText('Date'));
|
|
893
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
894
|
+
|
|
895
|
+
await waitFor(() => expect(screen.getByText('Today')).toBeInTheDocument());
|
|
896
|
+
fireEvent.click(screen.getByText('Today'));
|
|
897
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
898
|
+
'{{ ctx.date.today }}',
|
|
899
|
+
expect.objectContaining({ paths: ['date', 'today'] }),
|
|
900
|
+
);
|
|
901
|
+
});
|
|
810
902
|
});
|
|
@@ -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
|
+
});
|
|
@@ -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';
|
|
@@ -100,6 +101,127 @@ describe('VariableInput', () => {
|
|
|
100
101
|
);
|
|
101
102
|
});
|
|
102
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
|
+
|
|
103
225
|
it('should render FlowContextSelector button', async () => {
|
|
104
226
|
const flowContext = createTestFlowContext();
|
|
105
227
|
render(
|
|
@@ -112,6 +234,62 @@ describe('VariableInput', () => {
|
|
|
112
234
|
expect(selectorButton).toBeInTheDocument();
|
|
113
235
|
});
|
|
114
236
|
|
|
237
|
+
it('disables the FlowContextSelector button when disabled', async () => {
|
|
238
|
+
const flowContext = createTestFlowContext();
|
|
239
|
+
render(
|
|
240
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
241
|
+
<VariableInput value="test" metaTree={() => flowContext.getPropertyMetaTree()} disabled />
|
|
242
|
+
</TestFlowContextWrapper>,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const selectorButton = await screen.findByRole('button');
|
|
246
|
+
expect(selectorButton).toBeDisabled();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should not highlight the selector button for synthetic constant/null paths', async () => {
|
|
250
|
+
const flowContext = createTestFlowContext();
|
|
251
|
+
|
|
252
|
+
render(
|
|
253
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
254
|
+
<VariableInput
|
|
255
|
+
value=""
|
|
256
|
+
metaTree={[
|
|
257
|
+
{ name: 'constant', title: 'Constant', type: 'string', paths: ['constant'] },
|
|
258
|
+
{ name: 'null', title: 'Null', type: 'object', paths: ['null'] },
|
|
259
|
+
...flowContext.getPropertyMetaTree(),
|
|
260
|
+
]}
|
|
261
|
+
converters={{
|
|
262
|
+
renderInputComponent: (meta) => {
|
|
263
|
+
const first = meta?.paths?.[0];
|
|
264
|
+
if (first === 'constant') {
|
|
265
|
+
return (props: any) => <input aria-label="constant-value" {...props} />;
|
|
266
|
+
}
|
|
267
|
+
if (first === 'null') {
|
|
268
|
+
return () => <Input placeholder="<Null>" readOnly />;
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
},
|
|
272
|
+
resolveValueFromPath: (meta) => {
|
|
273
|
+
const first = meta?.paths?.[0];
|
|
274
|
+
if (first === 'constant') return '';
|
|
275
|
+
if (first === 'null') return null;
|
|
276
|
+
return undefined;
|
|
277
|
+
},
|
|
278
|
+
resolvePathFromValue: (currentValue) => {
|
|
279
|
+
if (currentValue === null) return ['null'];
|
|
280
|
+
const trimmed = typeof currentValue === 'string' ? currentValue.trim() : currentValue;
|
|
281
|
+
if (trimmed === '') return ['constant'];
|
|
282
|
+
return undefined;
|
|
283
|
+
},
|
|
284
|
+
}}
|
|
285
|
+
/>
|
|
286
|
+
</TestFlowContextWrapper>,
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const selectorButton = await screen.findByRole('button');
|
|
290
|
+
expect(selectorButton.className).not.toContain('ant-btn-primary');
|
|
291
|
+
});
|
|
292
|
+
|
|
115
293
|
it('should handle onChange from Input', async () => {
|
|
116
294
|
const onChange = vi.fn();
|
|
117
295
|
const flowContext = createTestFlowContext();
|
|
@@ -195,20 +373,21 @@ describe('VariableInput', () => {
|
|
|
195
373
|
|
|
196
374
|
const selectElement = container.querySelector('.ant-select');
|
|
197
375
|
expect(selectElement).toBeInTheDocument();
|
|
376
|
+
if (!selectElement) {
|
|
377
|
+
throw new Error('Expected variable tag select wrapper to be present');
|
|
378
|
+
}
|
|
198
379
|
|
|
199
380
|
// 触发鼠标悬停以显示清除按钮
|
|
200
|
-
fireEvent.mouseEnter(selectElement
|
|
381
|
+
fireEvent.mouseEnter(selectElement);
|
|
201
382
|
|
|
202
383
|
// 尝试触发清除功能
|
|
203
384
|
// 方法1: 直接触发 Select 组件的 onClear 事件
|
|
204
|
-
const selectInstance = selectElement as any;
|
|
205
|
-
|
|
206
385
|
// 尝试通过键盘事件触发清除
|
|
207
|
-
fireEvent.keyDown(selectElement
|
|
386
|
+
fireEvent.keyDown(selectElement, { key: 'Backspace', code: 'Backspace' });
|
|
208
387
|
|
|
209
388
|
// 或者尝试触发自定义的清除逻辑
|
|
210
389
|
const clearEvents = new CustomEvent('clear');
|
|
211
|
-
selectElement
|
|
390
|
+
selectElement.dispatchEvent(clearEvents);
|
|
212
391
|
|
|
213
392
|
// 检查是否调用了清除功能(可能需要调整期望)
|
|
214
393
|
// 如果清除按钮不能直接测试,我们验证组件支持清除功能
|
|
@@ -276,7 +455,24 @@ describe('VariableInput', () => {
|
|
|
276
455
|
expect(input).toHaveClass('custom-class');
|
|
277
456
|
|
|
278
457
|
// Note: The disabled prop might not be correctly passed through in the current implementation
|
|
279
|
-
|
|
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();
|
|
280
476
|
});
|
|
281
477
|
|
|
282
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
|
|