Skip to content

Commit 6994174

Browse files
Ivan AtanasovCharlieKolbMiloradFilipovicnetroy
authored andcommitted
feat: (Execute Workflow Node): Inputs for Sub-workflows (#11830) (#11837)
Co-authored-by: Charlie Kolb <[email protected]> Co-authored-by: Milorad FIlipović <[email protected]> Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <[email protected]>
1 parent e3c6696 commit 6994174

File tree

52 files changed

+4026
-691
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+4026
-691
lines changed

Diff for: cypress/e2e/48-subworkflow-inputs.cy.ts

+288
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import { clickGetBackToCanvas, getOutputTableHeaders } from '../composables/ndv';
2+
import {
3+
clickZoomToFit,
4+
navigateToNewWorkflowPage,
5+
openNode,
6+
pasteWorkflow,
7+
saveWorkflowOnButtonClick,
8+
} from '../composables/workflow';
9+
import SUB_WORKFLOW_INPUTS from '../fixtures/Test_Subworkflow-Inputs.json';
10+
import { NDV, WorkflowsPage, WorkflowPage } from '../pages';
11+
import { errorToast, successToast } from '../pages/notifications';
12+
import { getVisiblePopper } from '../utils';
13+
14+
const ndv = new NDV();
15+
const workflowsPage = new WorkflowsPage();
16+
const workflow = new WorkflowPage();
17+
18+
const DEFAULT_WORKFLOW_NAME = 'My workflow';
19+
const DEFAULT_SUBWORKFLOW_NAME_1 = 'My Sub-Workflow 1';
20+
const DEFAULT_SUBWORKFLOW_NAME_2 = 'My Sub-Workflow 2';
21+
22+
type FieldRow = readonly string[];
23+
24+
const exampleFields = [
25+
['aNumber', 'Number'],
26+
['aString', 'String'],
27+
['aArray', 'Array'],
28+
['aObject', 'Object'],
29+
['aAny', 'Allow Any Type'],
30+
// bool last since it's not an inputField so we'll skip it for some cases
31+
['aBool', 'Boolean'],
32+
] as const;
33+
34+
/**
35+
* Populate multiValue fixedCollections. Only supports fixedCollections for which all fields can be defined via keyboard typing
36+
*
37+
* @param items - 2D array of items to populate, i.e. [["myField1", "String"], [""]
38+
* @param collectionName - name of the fixedCollection to populate
39+
* @param offset - amount of 'parameter-input's before the fixedCollection under test
40+
* @returns
41+
*/
42+
function populateFixedCollection(
43+
items: readonly FieldRow[],
44+
collectionName: string,
45+
offset: number,
46+
) {
47+
if (items.length === 0) return;
48+
const n = items[0].length;
49+
for (const [i, params] of items.entries()) {
50+
ndv.actions.addItemToFixedCollection(collectionName);
51+
for (const [j, param] of params.entries()) {
52+
ndv.getters
53+
.fixedCollectionParameter(collectionName)
54+
.getByTestId('parameter-input')
55+
.eq(offset + i * n + j)
56+
.type(`${param}{downArrow}{enter}`);
57+
}
58+
}
59+
}
60+
61+
function makeExample(type: TypeField) {
62+
switch (type) {
63+
case 'String':
64+
return '"example"';
65+
case 'Number':
66+
return '42';
67+
case 'Boolean':
68+
return 'true';
69+
case 'Array':
70+
return '["example", 123, null]';
71+
case 'Object':
72+
return '{{}"example": [123]}';
73+
case 'Allow Any Type':
74+
return 'null';
75+
}
76+
}
77+
78+
type TypeField = 'Allow Any Type' | 'String' | 'Number' | 'Boolean' | 'Array' | 'Object';
79+
function populateFields(items: ReadonlyArray<readonly [string, TypeField]>) {
80+
populateFixedCollection(items, 'workflowInputs', 1);
81+
}
82+
83+
function navigateWorkflowSelectionDropdown(index: number, expectedText: string) {
84+
ndv.getters.resourceLocator('workflowId').should('be.visible');
85+
ndv.getters.resourceLocatorInput('workflowId').click();
86+
87+
getVisiblePopper().findChildByTestId('rlc-item').eq(0).should('exist');
88+
getVisiblePopper()
89+
.findChildByTestId('rlc-item')
90+
.eq(index)
91+
.find('span')
92+
.should('have.text', expectedText)
93+
.click();
94+
}
95+
96+
function populateMapperFields(values: readonly string[], offset: number) {
97+
for (const [i, value] of values.entries()) {
98+
cy.getByTestId('parameter-input')
99+
.eq(offset + i)
100+
.type(value);
101+
102+
// Click on a parent to dismiss the pop up hiding the field below.
103+
cy.getByTestId('parameter-input')
104+
.eq(offset + i)
105+
.parent()
106+
.parent()
107+
.click('topLeft');
108+
}
109+
}
110+
111+
// This function starts off in the Child Workflow Input Trigger, assuming we just defined the input fields
112+
// It then navigates back to the parent and validates output
113+
function validateAndReturnToParent(targetChild: string, offset: number, fields: string[]) {
114+
ndv.actions.execute();
115+
116+
// + 1 to account for formatting-only column
117+
getOutputTableHeaders().should('have.length', fields.length + 1);
118+
for (const [i, name] of fields.entries()) {
119+
getOutputTableHeaders().eq(i).should('have.text', name);
120+
}
121+
122+
clickGetBackToCanvas();
123+
saveWorkflowOnButtonClick();
124+
125+
cy.visit(workflowsPage.url);
126+
127+
workflowsPage.getters.workflowCardContent(DEFAULT_WORKFLOW_NAME).click();
128+
129+
openNode('Execute Workflow');
130+
131+
// Note that outside of e2e tests this will be pre-selected correctly.
132+
// Due to our workaround to remain in the same tab we need to select the correct tab manually
133+
navigateWorkflowSelectionDropdown(offset, targetChild);
134+
135+
// This fails, pointing to `usePushConnection` `const triggerNode = subWorkflow?.nodes.find` being `undefined.find()`I <think>
136+
ndv.actions.execute();
137+
138+
getOutputTableHeaders().should('have.length', fields.length + 1);
139+
for (const [i, name] of fields.entries()) {
140+
getOutputTableHeaders().eq(i).should('have.text', name);
141+
}
142+
143+
// todo: verify the fields appear and show the correct types
144+
145+
// todo: fill in the input fields (and mock previous node data in the json fixture to match)
146+
147+
// todo: validate the actual output data
148+
}
149+
150+
function setWorkflowInputFieldValue(index: number, value: string) {
151+
ndv.actions.addItemToFixedCollection('workflowInputs');
152+
ndv.actions.typeIntoFixedCollectionItem('workflowInputs', index, value);
153+
}
154+
155+
describe('Sub-workflow creation and typed usage', () => {
156+
beforeEach(() => {
157+
navigateToNewWorkflowPage();
158+
pasteWorkflow(SUB_WORKFLOW_INPUTS);
159+
saveWorkflowOnButtonClick();
160+
clickZoomToFit();
161+
162+
openNode('Execute Workflow');
163+
164+
// Prevent sub-workflow from opening in new window
165+
cy.window().then((win) => {
166+
cy.stub(win, 'open').callsFake((url) => {
167+
cy.visit(url);
168+
});
169+
});
170+
navigateWorkflowSelectionDropdown(0, 'Create a new sub-workflow');
171+
// **************************
172+
// NAVIGATE TO CHILD WORKFLOW
173+
// **************************
174+
175+
openNode('Workflow Input Trigger');
176+
});
177+
178+
it('works with type-checked values', () => {
179+
populateFields(exampleFields);
180+
181+
validateAndReturnToParent(
182+
DEFAULT_SUBWORKFLOW_NAME_1,
183+
1,
184+
exampleFields.map((f) => f[0]),
185+
);
186+
187+
const values = [
188+
'-1', // number fields don't support `=` switch to expression, so let's test the Fixed case with it
189+
...exampleFields.slice(1).map((x) => `={{}{{} $json.a${x[0]}`), // }} are added automatically
190+
];
191+
192+
// this matches with the pinned data provided in the fixture
193+
populateMapperFields(values, 2);
194+
195+
ndv.actions.execute();
196+
197+
// todo:
198+
// - validate output lines up
199+
// - change input to need casts
200+
// - run
201+
// - confirm error
202+
// - switch `attemptToConvertTypes` flag
203+
// - confirm success and changed output
204+
// - change input to be invalid despite cast
205+
// - run
206+
// - confirm error
207+
// - switch type option flags
208+
// - run
209+
// - confirm success
210+
// - turn off attempt to cast flag
211+
// - confirm a value was not cast
212+
});
213+
214+
it('works with Fields input source into JSON input source', () => {
215+
ndv.getters.nodeOutputHint().should('exist');
216+
217+
populateFields(exampleFields);
218+
219+
validateAndReturnToParent(
220+
DEFAULT_SUBWORKFLOW_NAME_1,
221+
1,
222+
exampleFields.map((f) => f[0]),
223+
);
224+
225+
cy.window().then((win) => {
226+
cy.stub(win, 'open').callsFake((url) => {
227+
cy.visit(url);
228+
});
229+
});
230+
navigateWorkflowSelectionDropdown(0, 'Create a new sub-workflow');
231+
232+
openNode('Workflow Input Trigger');
233+
234+
cy.getByTestId('parameter-input').eq(0).click();
235+
236+
// Todo: Check if there's a better way to interact with option dropdowns
237+
// This PR would add this child testId
238+
getVisiblePopper()
239+
.getByTestId('parameter-input')
240+
.eq(0)
241+
.type('Using JSON Example{downArrow}{enter}');
242+
243+
const exampleJson =
244+
'{{}' + exampleFields.map((x) => `"${x[0]}": ${makeExample(x[1])}`).join(',') + '}';
245+
cy.getByTestId('parameter-input-jsonExample')
246+
.find('.cm-line')
247+
.eq(0)
248+
.type(`{selectAll}{backspace}${exampleJson}{enter}`);
249+
250+
// first one doesn't work for some reason, might need to wait for something?
251+
ndv.actions.execute();
252+
253+
validateAndReturnToParent(
254+
DEFAULT_SUBWORKFLOW_NAME_2,
255+
2,
256+
exampleFields.map((f) => f[0]),
257+
);
258+
259+
// test for either InputSource mode and options combinations:
260+
// + we're showing the notice in the output panel
261+
// + we start with no fields
262+
// + Test Step works and we create the fields
263+
// + create field of each type (string, number, boolean, object, array, any)
264+
// + exit ndv
265+
// + save
266+
// + go back to parent workflow
267+
// - verify fields appear [needs Ivan's PR]
268+
// - link fields [needs Ivan's PR]
269+
// + run parent
270+
// - verify output with `null` defaults exists
271+
//
272+
});
273+
274+
it('should show node issue when no fields are defined in manual mode', () => {
275+
ndv.getters.nodeExecuteButton().should('be.disabled');
276+
ndv.actions.close();
277+
// Executing the workflow should show an error toast
278+
workflow.actions.executeWorkflow();
279+
errorToast().should('contain', 'The workflow has issues');
280+
openNode('Workflow Input Trigger');
281+
// Add a field to the workflowInputs fixedCollection
282+
setWorkflowInputFieldValue(0, 'test');
283+
// Executing the workflow should not show error now
284+
ndv.actions.close();
285+
workflow.actions.executeWorkflow();
286+
successToast().should('contain', 'Workflow executed successfully');
287+
});
288+
});

Diff for: cypress/fixtures/Test_Subworkflow-Inputs.json

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
{
2+
"meta": {
3+
"instanceId": "4d0676b62208d810ef035130bbfc9fd3afdc78d963ea8ccb9514dc89066efc94"
4+
},
5+
"nodes": [
6+
{
7+
"parameters": {},
8+
"id": "bb7f8bb3-840a-464c-a7de-d3a80538c2be",
9+
"name": "When clicking ‘Test workflow’",
10+
"type": "n8n-nodes-base.manualTrigger",
11+
"typeVersion": 1,
12+
"position": [0, 0]
13+
},
14+
{
15+
"parameters": {
16+
"workflowId": {},
17+
"workflowInputs": {
18+
"mappingMode": "defineBelow",
19+
"value": {},
20+
"matchingColumns": [],
21+
"schema": [],
22+
"ignoreTypeMismatchErrors": false,
23+
"attemptToConvertTypes": false,
24+
"convertFieldsToString": true
25+
},
26+
"options": {}
27+
},
28+
"type": "n8n-nodes-base.executeWorkflow",
29+
"typeVersion": 1.2,
30+
"position": [500, 240],
31+
"id": "6b6e2e34-c6ab-4083-b8e3-6b0d56be5453",
32+
"name": "Execute Workflow"
33+
}
34+
],
35+
"connections": {
36+
"When clicking ‘Test workflow’": {
37+
"main": [
38+
[
39+
{
40+
"node": "Execute Workflow",
41+
"type": "main",
42+
"index": 0
43+
}
44+
]
45+
]
46+
}
47+
},
48+
"pinData": {
49+
"When clicking ‘Test workflow’": [
50+
{
51+
"aaString": "A String",
52+
"aaNumber": 1,
53+
"aaArray": [1, true, "3"],
54+
"aaObject": {
55+
"aKey": -1
56+
},
57+
"aaAny": {}
58+
},
59+
{
60+
"aaString": "Another String",
61+
"aaNumber": 2,
62+
"aaArray": [],
63+
"aaObject": {
64+
"aDifferentKey": -1
65+
},
66+
"aaAny": []
67+
}
68+
]
69+
}
70+
}

Diff for: cypress/pages/ndv.ts

+5
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,11 @@ export class NDV extends BasePage {
320320
addItemToFixedCollection: (paramName: string) => {
321321
this.getters.fixedCollectionParameter(paramName).getByTestId('fixed-collection-add').click();
322322
},
323+
typeIntoFixedCollectionItem: (fixedCollectionName: string, index: number, content: string) => {
324+
this.getters.fixedCollectionParameter(fixedCollectionName).within(() => {
325+
cy.getByTestId('parameter-input').eq(index).type(content);
326+
});
327+
},
323328
dragMainPanelToLeft: () => {
324329
cy.drag('[data-test-id=panel-drag-button]', [-1000, 0], { moveTwice: true });
325330
},

0 commit comments

Comments
 (0)