mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-12 21:37:32 -08:00
feat(core): Improve debugging of sub-workflows (#11602)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Benchmark Docker Image CI / build (push) Waiting to run
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Benchmark Docker Image CI / build (push) Waiting to run
This commit is contained in:
parent
f4ca4b792f
commit
fd3254d587
29
cypress/composables/executions.ts
Normal file
29
cypress/composables/executions.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Getters
|
||||
*/
|
||||
|
||||
export const getExecutionsSidebar = () => cy.getByTestId('executions-sidebar');
|
||||
|
||||
export const getWorkflowExecutionPreviewIframe = () => cy.getByTestId('workflow-preview-iframe');
|
||||
|
||||
export const getExecutionPreviewBody = () =>
|
||||
getWorkflowExecutionPreviewIframe()
|
||||
.its('0.contentDocument.body')
|
||||
.then((el) => cy.wrap(el));
|
||||
|
||||
export const getExecutionPreviewBodyNodes = () =>
|
||||
getExecutionPreviewBody().findChildByTestId('canvas-node');
|
||||
|
||||
export const getExecutionPreviewBodyNodesByName = (name: string) =>
|
||||
getExecutionPreviewBody().findChildByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0);
|
||||
|
||||
export function getExecutionPreviewOutputPanelRelatedExecutionLink() {
|
||||
return getExecutionPreviewBody().findChildByTestId('related-execution-link');
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
|
||||
export const openExecutionPreviewNode = (name: string) =>
|
||||
getExecutionPreviewBodyNodesByName(name).dblclick();
|
|
@ -48,10 +48,38 @@ export function getOutputTableRow(row: number) {
|
|||
return getOutputTableRows().eq(row);
|
||||
}
|
||||
|
||||
export function getOutputTableHeaders() {
|
||||
return getOutputPanelDataContainer().find('table thead th');
|
||||
}
|
||||
|
||||
export function getOutputTableHeaderByText(text: string) {
|
||||
return getOutputTableHeaders().contains(text);
|
||||
}
|
||||
|
||||
export function getOutputTbodyCell(row: number, col: number) {
|
||||
return getOutputTableRows().eq(row).find('td').eq(col);
|
||||
}
|
||||
|
||||
export function getOutputRunSelector() {
|
||||
return getOutputPanel().findChildByTestId('run-selector');
|
||||
}
|
||||
|
||||
export function getOutputRunSelectorInput() {
|
||||
return getOutputRunSelector().find('input');
|
||||
}
|
||||
|
||||
export function getOutputPanelTable() {
|
||||
return getOutputPanelDataContainer().get('table');
|
||||
}
|
||||
|
||||
export function getOutputPanelItemsCount() {
|
||||
return getOutputPanel().getByTestId('ndv-items-count');
|
||||
}
|
||||
|
||||
export function getOutputPanelRelatedExecutionLink() {
|
||||
return getOutputPanel().getByTestId('related-execution-link');
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
|
@ -90,3 +118,8 @@ export function setParameterSelectByContent(name: string, content: string) {
|
|||
getParameterInputByName(name).realClick();
|
||||
getVisibleSelect().find('.option-headline').contains(content).click();
|
||||
}
|
||||
|
||||
export function changeOutputRunSelector(runName: string) {
|
||||
getOutputRunSelector().click();
|
||||
getVisibleSelect().find('.el-select-dropdown__item').contains(runName).click();
|
||||
}
|
||||
|
|
|
@ -76,6 +76,14 @@ export function getCanvasNodes() {
|
|||
);
|
||||
}
|
||||
|
||||
export function getSaveButton() {
|
||||
return cy.getByTestId('workflow-save-button');
|
||||
}
|
||||
|
||||
export function getZoomToFitButton() {
|
||||
return cy.getByTestId('zoom-to-fit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
|
@ -170,3 +178,19 @@ export function clickManualChatButton() {
|
|||
export function openNode(nodeName: string) {
|
||||
getNodeByName(nodeName).dblclick();
|
||||
}
|
||||
|
||||
export function saveWorkflowOnButtonClick() {
|
||||
cy.intercept('POST', '/rest/workflows').as('createWorkflow');
|
||||
getSaveButton().should('contain', 'Save');
|
||||
getSaveButton().click();
|
||||
getSaveButton().should('contain', 'Saved');
|
||||
cy.url().should('not.have.string', '/new');
|
||||
}
|
||||
|
||||
export function pasteWorkflow(workflow: object) {
|
||||
cy.get('body').paste(JSON.stringify(workflow));
|
||||
}
|
||||
|
||||
export function clickZoomToFit() {
|
||||
getZoomToFitButton().click();
|
||||
}
|
||||
|
|
|
@ -31,29 +31,31 @@ describe('NDV', () => {
|
|||
|
||||
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
|
||||
ndv.getters.inputTableRow(1).realHover();
|
||||
ndv.actions.dragMainPanelToRight();
|
||||
ndv.getters.inputTableRow(1).realMouseMove(10, 1);
|
||||
ndv.getters.outputTableRow(4).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
|
||||
ndv.getters.inputTableRow(2).realHover();
|
||||
ndv.getters.inputTableRow(2).realMouseMove(10, 1);
|
||||
ndv.getters.outputTableRow(2).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
|
||||
ndv.getters.inputTableRow(3).realHover();
|
||||
ndv.getters.inputTableRow(3).realMouseMove(10, 1);
|
||||
ndv.getters.outputTableRow(6).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
|
||||
// output to input
|
||||
ndv.getters.outputTableRow(1).realHover();
|
||||
ndv.actions.dragMainPanelToLeft();
|
||||
ndv.getters.outputTableRow(1).realMouseMove(10, 1);
|
||||
ndv.getters.inputTableRow(4).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
|
||||
ndv.getters.outputTableRow(4).realHover();
|
||||
ndv.getters.outputTableRow(4).realMouseMove(10, 1);
|
||||
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
|
||||
ndv.getters.outputTableRow(2).realHover();
|
||||
ndv.getters.outputTableRow(2).realMouseMove(10, 1);
|
||||
ndv.getters.inputTableRow(2).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
|
||||
ndv.getters.outputTableRow(6).realHover();
|
||||
ndv.getters.outputTableRow(6).realMouseMove(10, 1);
|
||||
ndv.getters.inputTableRow(3).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
|
||||
ndv.getters.outputTableRow(1).realHover();
|
||||
ndv.getters.outputTableRow(1).realMouseMove(10, 1);
|
||||
ndv.getters.inputTableRow(4).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
});
|
||||
|
||||
|
@ -75,31 +77,32 @@ describe('NDV', () => {
|
|||
ndv.actions.switchInputMode('Table');
|
||||
ndv.actions.switchOutputMode('Table');
|
||||
|
||||
ndv.getters.backToCanvas().realHover(); // reset to default hover
|
||||
ndv.getters.backToCanvas().realMouseMove(10, 1); // reset to default hover
|
||||
ndv.getters.outputHoveringItem().should('not.exist');
|
||||
ndv.getters.parameterExpressionPreview('value').should('include.text', '1111');
|
||||
|
||||
ndv.actions.selectInputNode('Set1');
|
||||
ndv.getters.backToCanvas().realHover(); // reset to default hover
|
||||
ndv.getters.backToCanvas().realMouseMove(10, 1); // reset to default hover
|
||||
|
||||
ndv.getters.inputTableRow(1).should('have.text', '1000');
|
||||
|
||||
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
|
||||
ndv.getters.inputTableRow(1).realHover();
|
||||
cy.wait(50);
|
||||
ndv.actions.dragMainPanelToRight();
|
||||
ndv.getters.inputTbodyCell(1, 0).realMouseMove(10, 1);
|
||||
ndv.getters.outputHoveringItem().should('have.text', '1000');
|
||||
ndv.getters.parameterExpressionPreview('value').should('include.text', '1000');
|
||||
|
||||
ndv.actions.selectInputNode('Sort');
|
||||
ndv.actions.dragMainPanelToLeft();
|
||||
ndv.actions.changeOutputRunSelector('1 of 2 (6 items)');
|
||||
ndv.getters.backToCanvas().realHover(); // reset to default hover
|
||||
ndv.getters.backToCanvas().realMouseMove(10, 1); // reset to default hover
|
||||
|
||||
ndv.getters.inputTableRow(1).should('have.text', '1111');
|
||||
|
||||
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
ndv.getters.inputTableRow(1).realHover();
|
||||
cy.wait(50);
|
||||
ndv.actions.dragMainPanelToRight();
|
||||
ndv.getters.inputTbodyCell(1, 0).realMouseMove(10, 1);
|
||||
ndv.getters.outputHoveringItem().should('have.text', '1111');
|
||||
ndv.getters.parameterExpressionPreview('value').should('include.text', '1111');
|
||||
});
|
||||
|
@ -132,20 +135,22 @@ describe('NDV', () => {
|
|||
|
||||
ndv.getters.inputTableRow(1).should('have.text', '1111');
|
||||
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
|
||||
ndv.actions.dragMainPanelToLeft();
|
||||
ndv.getters.outputTableRow(1).should('have.text', '1111');
|
||||
ndv.getters.outputTableRow(1).realHover();
|
||||
ndv.getters.outputTableRow(1).realMouseMove(10, 1);
|
||||
|
||||
ndv.getters.outputTableRow(3).should('have.text', '4444');
|
||||
ndv.getters.outputTableRow(3).realHover();
|
||||
ndv.getters.outputTableRow(3).realMouseMove(10, 1);
|
||||
|
||||
ndv.getters.inputTableRow(3).should('have.text', '4444');
|
||||
ndv.getters.inputTableRow(3).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
|
||||
ndv.actions.changeOutputRunSelector('2 of 2 (6 items)');
|
||||
cy.wait(50);
|
||||
|
||||
ndv.getters.inputTableRow(1).should('have.text', '1000');
|
||||
ndv.getters.inputTableRow(1).realHover();
|
||||
ndv.actions.dragMainPanelToRight();
|
||||
ndv.getters.inputTableRow(1).realMouseMove(10, 1);
|
||||
|
||||
ndv.getters.outputTableRow(1).should('have.text', '1000');
|
||||
ndv.getters
|
||||
|
@ -155,7 +160,8 @@ describe('NDV', () => {
|
|||
.should('equal', 'hovering-item');
|
||||
|
||||
ndv.getters.outputTableRow(3).should('have.text', '2000');
|
||||
ndv.getters.outputTableRow(3).realHover();
|
||||
ndv.actions.dragMainPanelToLeft();
|
||||
ndv.getters.outputTableRow(3).realMouseMove(10, 1);
|
||||
|
||||
ndv.getters.inputTableRow(3).should('have.text', '2000');
|
||||
|
||||
|
@ -175,14 +181,15 @@ describe('NDV', () => {
|
|||
|
||||
ndv.actions.switchOutputBranch('False Branch (2 items)');
|
||||
ndv.getters.outputTableRow(1).should('have.text', '8888');
|
||||
ndv.getters.outputTableRow(1).realHover();
|
||||
ndv.actions.dragMainPanelToLeft();
|
||||
ndv.getters.outputTableRow(1).realMouseMove(10, 1);
|
||||
|
||||
ndv.getters.inputTableRow(5).should('have.text', '8888');
|
||||
|
||||
ndv.getters.inputTableRow(5).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
|
||||
|
||||
ndv.getters.outputTableRow(2).should('have.text', '9999');
|
||||
ndv.getters.outputTableRow(2).realHover();
|
||||
ndv.getters.outputTableRow(2).realMouseMove(10, 1);
|
||||
|
||||
ndv.getters.inputTableRow(6).should('have.text', '9999');
|
||||
|
||||
|
@ -192,29 +199,35 @@ describe('NDV', () => {
|
|||
|
||||
workflowPage.actions.openNode('Set5');
|
||||
|
||||
ndv.actions.dragMainPanelToRight();
|
||||
ndv.actions.switchInputBranch('True Branch');
|
||||
|
||||
ndv.actions.dragMainPanelToLeft();
|
||||
ndv.actions.changeOutputRunSelector('1 of 2 (2 items)');
|
||||
ndv.getters.outputTableRow(1).should('have.text', '8888');
|
||||
ndv.getters.outputTableRow(1).realHover();
|
||||
cy.wait(100);
|
||||
ndv.getters.outputTableRow(1).realMouseMove(10, 1);
|
||||
ndv.getters.inputHoveringItem().should('not.exist');
|
||||
|
||||
ndv.getters.inputTableRow(1).should('have.text', '1111');
|
||||
ndv.getters.inputTableRow(1).realHover();
|
||||
cy.wait(100);
|
||||
|
||||
ndv.actions.dragMainPanelToRight();
|
||||
ndv.getters.inputTableRow(1).realMouseMove(10, 1);
|
||||
ndv.getters.outputHoveringItem().should('not.exist');
|
||||
|
||||
ndv.actions.switchInputBranch('False Branch');
|
||||
ndv.getters.inputTableRow(1).should('have.text', '8888');
|
||||
ndv.getters.inputTableRow(1).realHover();
|
||||
ndv.actions.dragMainPanelToRight();
|
||||
ndv.getters.inputTableRow(1).realMouseMove(10, 1);
|
||||
|
||||
ndv.actions.dragMainPanelToLeft();
|
||||
ndv.actions.changeOutputRunSelector('2 of 2 (4 items)');
|
||||
ndv.getters.outputTableRow(1).should('have.text', '1111');
|
||||
ndv.getters.outputTableRow(1).realHover();
|
||||
ndv.getters.outputTableRow(1).realMouseMove(10, 1);
|
||||
|
||||
ndv.actions.changeOutputRunSelector('1 of 2 (2 items)');
|
||||
ndv.getters.inputTableRow(1).should('have.text', '8888');
|
||||
ndv.getters.inputTableRow(1).realHover();
|
||||
ndv.actions.dragMainPanelToRight();
|
||||
ndv.getters.inputTableRow(1).realMouseMove(10, 1);
|
||||
ndv.getters.outputHoveringItem().should('have.text', '8888');
|
||||
// todo there's a bug here need to fix ADO-534
|
||||
// ndv.getters.outputHoveringItem().should('not.exist');
|
||||
|
|
140
cypress/e2e/47-subworkflow-debugging.cy.ts
Normal file
140
cypress/e2e/47-subworkflow-debugging.cy.ts
Normal file
|
@ -0,0 +1,140 @@
|
|||
import {
|
||||
getExecutionPreviewOutputPanelRelatedExecutionLink,
|
||||
getExecutionsSidebar,
|
||||
getWorkflowExecutionPreviewIframe,
|
||||
openExecutionPreviewNode,
|
||||
} from '../composables/executions';
|
||||
import {
|
||||
changeOutputRunSelector,
|
||||
getOutputPanelItemsCount,
|
||||
getOutputPanelRelatedExecutionLink,
|
||||
getOutputRunSelectorInput,
|
||||
getOutputTableHeaders,
|
||||
getOutputTableRows,
|
||||
getOutputTbodyCell,
|
||||
} from '../composables/ndv';
|
||||
import {
|
||||
clickExecuteWorkflowButton,
|
||||
clickZoomToFit,
|
||||
getCanvasNodes,
|
||||
navigateToNewWorkflowPage,
|
||||
openNode,
|
||||
pasteWorkflow,
|
||||
saveWorkflowOnButtonClick,
|
||||
} from '../composables/workflow';
|
||||
import SUBWORKFLOW_DEBUGGING_EXAMPLE from '../fixtures/Subworkflow-debugging-execute-workflow.json';
|
||||
|
||||
describe('Subworkflow debugging', () => {
|
||||
beforeEach(() => {
|
||||
navigateToNewWorkflowPage();
|
||||
pasteWorkflow(SUBWORKFLOW_DEBUGGING_EXAMPLE);
|
||||
saveWorkflowOnButtonClick();
|
||||
getCanvasNodes().should('have.length', 11);
|
||||
clickZoomToFit();
|
||||
|
||||
clickExecuteWorkflowButton();
|
||||
});
|
||||
|
||||
describe('can inspect sub executed workflow', () => {
|
||||
it('(Run once with all items/ Wait for Sub-workflow completion) (default behavior)', () => {
|
||||
openNode('Execute Workflow with param');
|
||||
|
||||
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
|
||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
|
||||
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
||||
|
||||
// ensure workflow executed and waited on output
|
||||
getOutputTableHeaders().should('have.length', 2);
|
||||
getOutputTbodyCell(1, 0).should('have.text', 'world Natalie Moore');
|
||||
});
|
||||
|
||||
it('(Run once for each item/ Wait for Sub-workflow completion)', () => {
|
||||
openNode('Execute Workflow with param1');
|
||||
|
||||
getOutputPanelItemsCount().should('contain.text', '2 items, 2 sub-execution');
|
||||
getOutputPanelRelatedExecutionLink().should('not.exist');
|
||||
|
||||
// ensure workflow executed and waited on output
|
||||
getOutputTableHeaders().should('have.length', 3);
|
||||
getOutputTbodyCell(1, 0).find('a').should('have.attr', 'href');
|
||||
getOutputTbodyCell(1, 1).should('have.text', 'world Natalie Moore');
|
||||
});
|
||||
|
||||
it('(Run once with all items/ Wait for Sub-workflow completion)', () => {
|
||||
openNode('Execute Workflow with param2');
|
||||
|
||||
getOutputPanelItemsCount().should('not.exist');
|
||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
|
||||
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
||||
|
||||
// ensure workflow executed but returned same data as input
|
||||
getOutputRunSelectorInput().should('have.value', '2 of 2 (3 items, 1 sub-execution)');
|
||||
getOutputTableHeaders().should('have.length', 6);
|
||||
getOutputTableHeaders().eq(0).should('have.text', 'uid');
|
||||
getOutputTableRows().should('have.length', 4);
|
||||
getOutputTbodyCell(1, 1).should('include.text', 'Jon_Ebert@yahoo.com');
|
||||
|
||||
changeOutputRunSelector('1 of 2 (2 items, 1 sub-execution)');
|
||||
getOutputRunSelectorInput().should('have.value', '1 of 2 (2 items, 1 sub-execution)');
|
||||
getOutputTableHeaders().should('have.length', 6);
|
||||
getOutputTableHeaders().eq(0).should('have.text', 'uid');
|
||||
getOutputTableRows().should('have.length', 3);
|
||||
getOutputTbodyCell(1, 1).should('include.text', 'Terry.Dach@hotmail.com');
|
||||
});
|
||||
|
||||
it('(Run once for each item/ Wait for Sub-workflow completion)', () => {
|
||||
openNode('Execute Workflow with param3');
|
||||
|
||||
// ensure workflow executed but returned same data as input
|
||||
getOutputRunSelectorInput().should('have.value', '2 of 2 (3 items, 3 sub-executions)');
|
||||
getOutputTableHeaders().should('have.length', 7);
|
||||
getOutputTableHeaders().eq(1).should('have.text', 'uid');
|
||||
getOutputTableRows().should('have.length', 4);
|
||||
getOutputTbodyCell(1, 0).find('a').should('have.attr', 'href');
|
||||
getOutputTbodyCell(1, 2).should('include.text', 'Jon_Ebert@yahoo.com');
|
||||
|
||||
changeOutputRunSelector('1 of 2 (2 items, 2 sub-executions)');
|
||||
getOutputRunSelectorInput().should('have.value', '1 of 2 (2 items, 2 sub-executions)');
|
||||
getOutputTableHeaders().should('have.length', 7);
|
||||
getOutputTableHeaders().eq(1).should('have.text', 'uid');
|
||||
getOutputTableRows().should('have.length', 3);
|
||||
|
||||
getOutputTbodyCell(1, 0).find('a').should('have.attr', 'href');
|
||||
getOutputTbodyCell(1, 2).should('include.text', 'Terry.Dach@hotmail.com');
|
||||
});
|
||||
});
|
||||
|
||||
it('can inspect parent executions', () => {
|
||||
cy.url().then((workflowUrl) => {
|
||||
openNode('Execute Workflow with param');
|
||||
|
||||
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
|
||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
|
||||
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
||||
|
||||
// ensure workflow executed and waited on output
|
||||
getOutputTableHeaders().should('have.length', 2);
|
||||
getOutputTbodyCell(1, 0).should('have.text', 'world Natalie Moore');
|
||||
|
||||
// cypress cannot handle new tabs so removing it
|
||||
getOutputPanelRelatedExecutionLink().invoke('removeAttr', 'target').click();
|
||||
|
||||
getExecutionsSidebar().should('be.visible');
|
||||
getWorkflowExecutionPreviewIframe().should('be.visible');
|
||||
openExecutionPreviewNode('Execute Workflow Trigger');
|
||||
|
||||
getExecutionPreviewOutputPanelRelatedExecutionLink().should(
|
||||
'include.text',
|
||||
'Inspect Parent Execution',
|
||||
);
|
||||
|
||||
getExecutionPreviewOutputPanelRelatedExecutionLink()
|
||||
.invoke('removeAttr', 'target')
|
||||
.click({ force: true });
|
||||
|
||||
cy.url().then((currentUrl) => {
|
||||
expect(currentUrl === workflowUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
354
cypress/fixtures/Subworkflow-debugging-execute-workflow.json
Normal file
354
cypress/fixtures/Subworkflow-debugging-execute-workflow.json
Normal file
|
@ -0,0 +1,354 @@
|
|||
{
|
||||
"meta": {
|
||||
"instanceId": "08ce71ad998aeaade0abedb8dd96153d8eaa03fcb84cfccc1530095bf9ee478e"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "4535ce3e-280e-49b0-8854-373472ec86d1",
|
||||
"name": "When clicking ‘Test workflow’",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [80, 860]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"category": "randomData",
|
||||
"randomDataSeed": "0",
|
||||
"randomDataCount": 2
|
||||
},
|
||||
"id": "d7fba18a-d51f-4509-af45-68cd9425ac6b",
|
||||
"name": "DebugHelper1",
|
||||
"type": "n8n-nodes-base.debugHelper",
|
||||
"typeVersion": 1,
|
||||
"position": [280, 860]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"source": "parameter",
|
||||
"workflowJson": "{\n \"meta\": {\n \"instanceId\": \"a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0\"\n },\n \"nodes\": [\n {\n \"parameters\": {},\n \"type\": \"n8n-nodes-base.executeWorkflowTrigger\",\n \"typeVersion\": 1,\n \"position\": [\n 0,\n 0\n ],\n \"id\": \"00600a51-e63a-4b6e-93f5-f01d50a21e0c\",\n \"name\": \"Execute Workflow Trigger\"\n },\n {\n \"parameters\": {\n \"assignments\": {\n \"assignments\": [\n {\n \"id\": \"87ff01af-2e28-48da-ae6c-304040200b15\",\n \"name\": \"hello\",\n \"value\": \"=world {{ $json.firstname }} {{ $json.lastname }}\",\n \"type\": \"string\"\n }\n ]\n },\n \"includeOtherFields\": false,\n \"options\": {}\n },\n \"type\": \"n8n-nodes-base.set\",\n \"typeVersion\": 3.4,\n \"position\": [\n 280,\n 0\n ],\n \"id\": \"642219a1-d655-4a30-af5c-fcccbb690322\",\n \"name\": \"Edit Fields\"\n }\n ],\n \"connections\": {\n \"Execute Workflow Trigger\": {\n \"main\": [\n [\n {\n \"node\": \"Edit Fields\",\n \"type\": \"main\",\n \"index\": 0\n }\n ]\n ]\n }\n },\n \"pinData\": {}\n}",
|
||||
"mode": "each",
|
||||
"options": {
|
||||
"waitForSubWorkflow": false
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.executeWorkflow",
|
||||
"typeVersion": 1.1,
|
||||
"position": [680, 1540],
|
||||
"id": "f90a25da-dd89-4bf8-8f5b-bf8ee1de0b70",
|
||||
"name": "Execute Workflow with param3"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "c93f26bd-3489-467b-909e-6462e1463707",
|
||||
"name": "uid",
|
||||
"value": "={{ $json.uid }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "3dd706ce-d925-4219-8531-ad12369972fe",
|
||||
"name": "email",
|
||||
"value": "={{ $json.email }}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [900, 1540],
|
||||
"id": "3be57648-3be8-4b0f-abfa-8fdcafee804d",
|
||||
"name": "Edit Fields8"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"source": "parameter",
|
||||
"workflowJson": "{\n \"meta\": {\n \"instanceId\": \"a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0\"\n },\n \"nodes\": [\n {\n \"parameters\": {},\n \"type\": \"n8n-nodes-base.executeWorkflowTrigger\",\n \"typeVersion\": 1,\n \"position\": [\n 0,\n 0\n ],\n \"id\": \"00600a51-e63a-4b6e-93f5-f01d50a21e0c\",\n \"name\": \"Execute Workflow Trigger\"\n },\n {\n \"parameters\": {\n \"assignments\": {\n \"assignments\": [\n {\n \"id\": \"87ff01af-2e28-48da-ae6c-304040200b15\",\n \"name\": \"hello\",\n \"value\": \"=world {{ $json.firstname }} {{ $json.lastname }}\",\n \"type\": \"string\"\n }\n ]\n },\n \"includeOtherFields\": false,\n \"options\": {}\n },\n \"type\": \"n8n-nodes-base.set\",\n \"typeVersion\": 3.4,\n \"position\": [\n 280,\n 0\n ],\n \"id\": \"642219a1-d655-4a30-af5c-fcccbb690322\",\n \"name\": \"Edit Fields\"\n }\n ],\n \"connections\": {\n \"Execute Workflow Trigger\": {\n \"main\": [\n [\n {\n \"node\": \"Edit Fields\",\n \"type\": \"main\",\n \"index\": 0\n }\n ]\n ]\n }\n },\n \"pinData\": {}\n}",
|
||||
"options": {
|
||||
"waitForSubWorkflow": false
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.executeWorkflow",
|
||||
"typeVersion": 1.1,
|
||||
"position": [620, 1220],
|
||||
"id": "dabc2356-3660-4d17-b305-936a002029ba",
|
||||
"name": "Execute Workflow with param2"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "c93f26bd-3489-467b-909e-6462e1463707",
|
||||
"name": "uid",
|
||||
"value": "={{ $json.uid }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "3dd706ce-d925-4219-8531-ad12369972fe",
|
||||
"name": "email",
|
||||
"value": "={{ $json.email }}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [840, 1220],
|
||||
"id": "9d2a9dda-e2a1-43e8-a66f-a8a555692e5f",
|
||||
"name": "Edit Fields7"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"source": "parameter",
|
||||
"workflowJson": "{\n \"meta\": {\n \"instanceId\": \"a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0\"\n },\n \"nodes\": [\n {\n \"parameters\": {},\n \"type\": \"n8n-nodes-base.executeWorkflowTrigger\",\n \"typeVersion\": 1,\n \"position\": [\n 0,\n 0\n ],\n \"id\": \"00600a51-e63a-4b6e-93f5-f01d50a21e0c\",\n \"name\": \"Execute Workflow Trigger\"\n },\n {\n \"parameters\": {\n \"assignments\": {\n \"assignments\": [\n {\n \"id\": \"87ff01af-2e28-48da-ae6c-304040200b15\",\n \"name\": \"hello\",\n \"value\": \"=world {{ $json.firstname }} {{ $json.lastname }}\",\n \"type\": \"string\"\n }\n ]\n },\n \"includeOtherFields\": false,\n \"options\": {}\n },\n \"type\": \"n8n-nodes-base.set\",\n \"typeVersion\": 3.4,\n \"position\": [\n 280,\n 0\n ],\n \"id\": \"642219a1-d655-4a30-af5c-fcccbb690322\",\n \"name\": \"Edit Fields\"\n }\n ],\n \"connections\": {\n \"Execute Workflow Trigger\": {\n \"main\": [\n [\n {\n \"node\": \"Edit Fields\",\n \"type\": \"main\",\n \"index\": 0\n }\n ]\n ]\n }\n },\n \"pinData\": {}\n}",
|
||||
"mode": "each",
|
||||
"options": {
|
||||
"waitForSubWorkflow": true
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.executeWorkflow",
|
||||
"typeVersion": 1.1,
|
||||
"position": [560, 900],
|
||||
"id": "07e47f60-622a-484c-ab24-35f6f2280595",
|
||||
"name": "Execute Workflow with param1"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "c93f26bd-3489-467b-909e-6462e1463707",
|
||||
"name": "uid",
|
||||
"value": "={{ $json.uid }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "3dd706ce-d925-4219-8531-ad12369972fe",
|
||||
"name": "email",
|
||||
"value": "={{ $json.email }}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [760, 900],
|
||||
"id": "80563d0a-0bab-444f-a04c-4041a505d78b",
|
||||
"name": "Edit Fields6"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"source": "parameter",
|
||||
"workflowJson": "{\n \"meta\": {\n \"instanceId\": \"a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0\"\n },\n \"nodes\": [\n {\n \"parameters\": {},\n \"type\": \"n8n-nodes-base.executeWorkflowTrigger\",\n \"typeVersion\": 1,\n \"position\": [\n 0,\n 0\n ],\n \"id\": \"00600a51-e63a-4b6e-93f5-f01d50a21e0c\",\n \"name\": \"Execute Workflow Trigger\"\n },\n {\n \"parameters\": {\n \"assignments\": {\n \"assignments\": [\n {\n \"id\": \"87ff01af-2e28-48da-ae6c-304040200b15\",\n \"name\": \"hello\",\n \"value\": \"=world {{ $json.firstname }} {{ $json.lastname }}\",\n \"type\": \"string\"\n }\n ]\n },\n \"includeOtherFields\": false,\n \"options\": {}\n },\n \"type\": \"n8n-nodes-base.set\",\n \"typeVersion\": 3.4,\n \"position\": [\n 280,\n 0\n ],\n \"id\": \"642219a1-d655-4a30-af5c-fcccbb690322\",\n \"name\": \"Edit Fields\"\n }\n ],\n \"connections\": {\n \"Execute Workflow Trigger\": {\n \"main\": [\n [\n {\n \"node\": \"Edit Fields\",\n \"type\": \"main\",\n \"index\": 0\n }\n ]\n ]\n }\n },\n \"pinData\": {}\n}",
|
||||
"options": {
|
||||
"waitForSubWorkflow": true
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.executeWorkflow",
|
||||
"typeVersion": 1.1,
|
||||
"position": [560, 580],
|
||||
"id": "f04af481-f4d9-4d91-a60a-a377580e8393",
|
||||
"name": "Execute Workflow with param"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "c93f26bd-3489-467b-909e-6462e1463707",
|
||||
"name": "uid",
|
||||
"value": "={{ $json.uid }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "3dd706ce-d925-4219-8531-ad12369972fe",
|
||||
"name": "email",
|
||||
"value": "={{ $json.email }}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [760, 580],
|
||||
"id": "80c10607-a0ac-4090-86a1-890da0a2aa52",
|
||||
"name": "Edit Fields2"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"content": "## Execute Workflow (Run once with all items/ DONT Wait for Sub-workflow completion)",
|
||||
"height": 254.84308966329985,
|
||||
"width": 457.58120569815793
|
||||
},
|
||||
"id": "534ef523-3453-4a16-9ff0-8ac9f025d47d",
|
||||
"name": "Sticky Note5",
|
||||
"type": "n8n-nodes-base.stickyNote",
|
||||
"typeVersion": 1,
|
||||
"position": [500, 1080]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"content": "## Execute Workflow (Run once with for each item/ DONT Wait for Sub-workflow completion) ",
|
||||
"height": 284.59778445962905,
|
||||
"width": 457.58120569815793
|
||||
},
|
||||
"id": "838f0fa3-5ee4-4d1a-afb8-42e009f1aa9e",
|
||||
"name": "Sticky Note4",
|
||||
"type": "n8n-nodes-base.stickyNote",
|
||||
"typeVersion": 1,
|
||||
"position": [580, 1400]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"category": "randomData",
|
||||
"randomDataSeed": "1",
|
||||
"randomDataCount": 3
|
||||
},
|
||||
"id": "86699a49-2aa7-488e-8ea9-828404c98f08",
|
||||
"name": "DebugHelper",
|
||||
"type": "n8n-nodes-base.debugHelper",
|
||||
"typeVersion": 1,
|
||||
"position": [320, 1120]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"content": "## Execute Workflow (Run once with for each item/ Wait for Sub-workflow completion) ",
|
||||
"height": 284.59778445962905,
|
||||
"width": 457.58120569815793
|
||||
},
|
||||
"id": "885d35f0-8ae6-45ec-821b-a82c27e7577a",
|
||||
"name": "Sticky Note3",
|
||||
"type": "n8n-nodes-base.stickyNote",
|
||||
"typeVersion": 1,
|
||||
"position": [480, 760]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"content": "## Execute Workflow (Run once with all items/ Wait for Sub-workflow completion) (default behavior)",
|
||||
"height": 254.84308966329985,
|
||||
"width": 457.58120569815793
|
||||
},
|
||||
"id": "505bd7f2-767e-41b8-9325-77300aed5883",
|
||||
"name": "Sticky Note2",
|
||||
"type": "n8n-nodes-base.stickyNote",
|
||||
"typeVersion": 1,
|
||||
"position": [460, 460]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When clicking ‘Test workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "DebugHelper1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "DebugHelper",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"DebugHelper1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Execute Workflow with param3",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Execute Workflow with param2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Execute Workflow with param1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Execute Workflow with param",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Execute Workflow with param3": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields8",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Execute Workflow with param2": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields7",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Execute Workflow with param1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields6",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Execute Workflow with param": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"DebugHelper": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Execute Workflow with param2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Execute Workflow with param3",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {}
|
||||
}
|
|
@ -323,6 +323,12 @@ export class NDV extends BasePage {
|
|||
addItemToFixedCollection: (paramName: string) => {
|
||||
this.getters.fixedCollectionParameter(paramName).getByTestId('fixed-collection-add').click();
|
||||
},
|
||||
dragMainPanelToLeft: () => {
|
||||
cy.drag('[data-test-id=panel-drag-button]', [-1000, 0], { moveTwice: true });
|
||||
},
|
||||
dragMainPanelToRight: () => {
|
||||
cy.drag('[data-test-id=panel-drag-button]', [1000, 0], { moveTwice: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -177,6 +177,16 @@ Cypress.Commands.add('drag', (selector, pos, options) => {
|
|||
pageY: newPosition.y,
|
||||
force: true,
|
||||
});
|
||||
if (options?.moveTwice) {
|
||||
// first move like hover to trigger object to be visible
|
||||
// like in main panel in ndv
|
||||
element.trigger('mousemove', {
|
||||
which: 1,
|
||||
pageX: newPosition.x,
|
||||
pageY: newPosition.y,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
if (options?.clickToFinish) {
|
||||
// Click to finish the drag
|
||||
// For some reason, mouseup isn't working when moving nodes
|
||||
|
|
|
@ -59,7 +59,13 @@ declare global {
|
|||
drag(
|
||||
selector: string | Chainable<JQuery<HTMLElement>>,
|
||||
target: [number, number],
|
||||
options?: { abs?: boolean; index?: number; realMouse?: boolean; clickToFinish?: boolean },
|
||||
options?: {
|
||||
abs?: boolean;
|
||||
index?: number;
|
||||
realMouse?: boolean;
|
||||
clickToFinish?: boolean;
|
||||
moveTwice?: boolean;
|
||||
},
|
||||
): void;
|
||||
draganddrop(
|
||||
draggableSelector: string,
|
||||
|
|
|
@ -10,6 +10,7 @@ import type {
|
|||
INodeTypeDescription,
|
||||
SupplyData,
|
||||
INodeParameterResourceLocator,
|
||||
ExecuteWorkflowData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { BaseRetriever, type BaseRetrieverInput } from '@langchain/core/retrievers';
|
||||
|
@ -293,6 +294,8 @@ export class RetrieverWorkflow implements INodeType {
|
|||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const workflowProxy = this.getWorkflowDataProxy(0);
|
||||
|
||||
class WorkflowRetriever extends BaseRetriever {
|
||||
lc_namespace = ['n8n-nodes-langchain', 'retrievers', 'workflow'];
|
||||
|
||||
|
@ -349,6 +352,9 @@ export class RetrieverWorkflow implements INodeType {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
// same as current workflow
|
||||
baseMetadata.workflowId = workflowProxy.$workflow.id;
|
||||
}
|
||||
|
||||
const rawData: IDataObject = { query };
|
||||
|
@ -384,21 +390,29 @@ export class RetrieverWorkflow implements INodeType {
|
|||
|
||||
const items = [newItem] as INodeExecutionData[];
|
||||
|
||||
let receivedItems: INodeExecutionData[][];
|
||||
let receivedData: ExecuteWorkflowData;
|
||||
try {
|
||||
receivedItems = (await this.executeFunctions.executeWorkflow(
|
||||
receivedData = await this.executeFunctions.executeWorkflow(
|
||||
workflowInfo,
|
||||
items,
|
||||
config?.getChild(),
|
||||
)) as INodeExecutionData[][];
|
||||
{
|
||||
parentExecution: {
|
||||
executionId: workflowProxy.$execution.id,
|
||||
workflowId: workflowProxy.$workflow.id,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// Make sure a valid error gets returned that can by json-serialized else it will
|
||||
// not show up in the frontend
|
||||
throw new NodeOperationError(this.executeFunctions.getNode(), error as Error);
|
||||
}
|
||||
|
||||
const receivedItems = receivedData.data?.[0] ?? [];
|
||||
|
||||
const returnData: Document[] = [];
|
||||
for (const [index, itemData] of receivedItems[0].entries()) {
|
||||
for (const [index, itemData] of receivedItems.entries()) {
|
||||
const pageContent = objectToString(itemData.json);
|
||||
returnData.push(
|
||||
new Document({
|
||||
|
@ -406,6 +420,7 @@ export class RetrieverWorkflow implements INodeType {
|
|||
metadata: {
|
||||
...baseMetadata,
|
||||
itemIndex: index,
|
||||
executionId: receivedData.executionId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
@ -14,8 +14,10 @@ import type {
|
|||
ISupplyDataFunctions,
|
||||
SupplyData,
|
||||
ExecutionError,
|
||||
ExecuteWorkflowData,
|
||||
IDataObject,
|
||||
INodeParameterResourceLocator,
|
||||
ITaskMetadata,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow';
|
||||
|
||||
|
@ -358,9 +360,14 @@ export class ToolWorkflow implements INodeType {
|
|||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const workflowProxy = this.getWorkflowDataProxy(0);
|
||||
|
||||
const name = this.getNodeParameter('name', itemIndex) as string;
|
||||
const description = this.getNodeParameter('description', itemIndex) as string;
|
||||
|
||||
let subExecutionId: string | undefined;
|
||||
let subWorkflowId: string | undefined;
|
||||
|
||||
const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean;
|
||||
let tool: DynamicTool | DynamicStructuredTool | undefined = undefined;
|
||||
|
||||
|
@ -396,11 +403,16 @@ export class ToolWorkflow implements INodeType {
|
|||
) as INodeParameterResourceLocator;
|
||||
workflowInfo.id = value as string;
|
||||
}
|
||||
|
||||
subWorkflowId = workflowInfo.id;
|
||||
} else if (source === 'parameter') {
|
||||
// Read workflow from parameter
|
||||
const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string;
|
||||
try {
|
||||
workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase;
|
||||
|
||||
// subworkflow is same as parent workflow
|
||||
subWorkflowId = workflowProxy.$workflow.id;
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
|
@ -440,13 +452,15 @@ export class ToolWorkflow implements INodeType {
|
|||
|
||||
const items = [newItem] as INodeExecutionData[];
|
||||
|
||||
let receivedData: INodeExecutionData;
|
||||
let receivedData: ExecuteWorkflowData;
|
||||
try {
|
||||
receivedData = (await this.executeWorkflow(
|
||||
workflowInfo,
|
||||
items,
|
||||
runManager?.getChild(),
|
||||
)) as INodeExecutionData;
|
||||
receivedData = await this.executeWorkflow(workflowInfo, items, runManager?.getChild(), {
|
||||
parentExecution: {
|
||||
executionId: workflowProxy.$execution.id,
|
||||
workflowId: workflowProxy.$workflow.id,
|
||||
},
|
||||
});
|
||||
subExecutionId = receivedData.executionId;
|
||||
} catch (error) {
|
||||
// Make sure a valid error gets returned that can by json-serialized else it will
|
||||
// not show up in the frontend
|
||||
|
@ -454,6 +468,7 @@ export class ToolWorkflow implements INodeType {
|
|||
}
|
||||
|
||||
const response: string | undefined = get(receivedData, [
|
||||
'data',
|
||||
0,
|
||||
0,
|
||||
'json',
|
||||
|
@ -503,10 +518,25 @@ export class ToolWorkflow implements INodeType {
|
|||
response = `There was an error: "${executionError.message}"`;
|
||||
}
|
||||
|
||||
let metadata: ITaskMetadata | undefined;
|
||||
if (subExecutionId && subWorkflowId) {
|
||||
metadata = {
|
||||
subExecution: {
|
||||
executionId: subExecutionId,
|
||||
workflowId: subWorkflowId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (executionError) {
|
||||
void this.addOutputData(NodeConnectionType.AiTool, index, executionError);
|
||||
void this.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata);
|
||||
} else {
|
||||
void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]);
|
||||
void this.addOutputData(
|
||||
NodeConnectionType.AiTool,
|
||||
index,
|
||||
[[{ json: { response } }]],
|
||||
metadata,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
|
|
@ -10,7 +10,12 @@ import type { Tool } from '@langchain/core/tools';
|
|||
import { VectorStore } from '@langchain/core/vectorstores';
|
||||
import { TextSplitter } from '@langchain/textsplitters';
|
||||
import type { BaseDocumentLoader } from 'langchain/dist/document_loaders/base';
|
||||
import type { IExecuteFunctions, INodeExecutionData, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
ISupplyDataFunctions,
|
||||
ITaskMetadata,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import { logAiEvent, isToolsInstance, isBaseChatMemory, isBaseChatMessageHistory } from './helpers';
|
||||
|
@ -220,8 +225,24 @@ export function logWrapper(
|
|||
arguments: [query, config],
|
||||
})) as Array<Document<Record<string, any>>>;
|
||||
|
||||
const executionId: string | undefined = response[0]?.metadata?.executionId as string;
|
||||
const workflowId: string | undefined = response[0]?.metadata?.workflowId as string;
|
||||
|
||||
const metadata: ITaskMetadata = {};
|
||||
if (executionId && workflowId) {
|
||||
metadata.subExecution = {
|
||||
executionId,
|
||||
workflowId,
|
||||
};
|
||||
}
|
||||
|
||||
logAiEvent(executeFunctions, 'ai-documents-retrieved', { query });
|
||||
executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]);
|
||||
executeFunctions.addOutputData(
|
||||
connectionType,
|
||||
index,
|
||||
[[{ json: { response } }]],
|
||||
metadata,
|
||||
);
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
"start:default": "cd bin && ./n8n",
|
||||
"start:windows": "cd bin && n8n",
|
||||
"test": "pnpm test:sqlite",
|
||||
"test:dev": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --watch",
|
||||
"test:sqlite": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest",
|
||||
"test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb DB_POSTGRESDB_SCHEMA=alt_schema DB_TABLE_PREFIX=test_ jest --no-coverage",
|
||||
"test:mysql": "N8N_LOG_LEVEL=silent DB_TYPE=mysqldb DB_TABLE_PREFIX=test_ jest --no-coverage",
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
IExecuteWorkflowInfo,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
ExecuteWorkflowOptions,
|
||||
IRun,
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
import {
|
||||
type IExecuteWorkflowInfo,
|
||||
type IWorkflowExecuteAdditionalData,
|
||||
type ExecuteWorkflowOptions,
|
||||
type IRun,
|
||||
type INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import type PCancelable from 'p-cancelable';
|
||||
import Container from 'typedi';
|
||||
|
@ -21,43 +23,59 @@ import { WorkflowStatisticsService } from '@/services/workflow-statistics.servic
|
|||
import { SubworkflowPolicyChecker } from '@/subworkflows/subworkflow-policy-checker.service';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { PermissionChecker } from '@/user-management/permission-checker';
|
||||
import { executeWorkflow, getBase } from '@/workflow-execute-additional-data';
|
||||
import { executeWorkflow, getBase, getRunData } from '@/workflow-execute-additional-data';
|
||||
import { mockInstance } from '@test/mocking';
|
||||
|
||||
const run = mock<IRun>({
|
||||
data: { resultData: {} },
|
||||
finished: true,
|
||||
mode: 'manual',
|
||||
startedAt: new Date(),
|
||||
status: 'new',
|
||||
});
|
||||
const EXECUTION_ID = '123';
|
||||
const LAST_NODE_EXECUTED = 'Last node executed';
|
||||
|
||||
const cancelablePromise = mock<PCancelable<IRun>>({
|
||||
then: jest
|
||||
.fn()
|
||||
.mockImplementation(async (onfulfilled) => await Promise.resolve(run).then(onfulfilled)),
|
||||
catch: jest
|
||||
.fn()
|
||||
.mockImplementation(async (onrejected) => await Promise.resolve(run).catch(onrejected)),
|
||||
finally: jest
|
||||
.fn()
|
||||
.mockImplementation(async (onfinally) => await Promise.resolve(run).finally(onfinally)),
|
||||
[Symbol.toStringTag]: 'PCancelable',
|
||||
});
|
||||
const getMockRun = ({ lastNodeOutput }: { lastNodeOutput: Array<INodeExecutionData[] | null> }) =>
|
||||
mock<IRun>({
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
[LAST_NODE_EXECUTED]: [
|
||||
{
|
||||
startTime: 100,
|
||||
data: {
|
||||
main: lastNodeOutput,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
lastNodeExecuted: LAST_NODE_EXECUTED,
|
||||
},
|
||||
},
|
||||
finished: true,
|
||||
mode: 'manual',
|
||||
startedAt: new Date(),
|
||||
status: 'new',
|
||||
});
|
||||
|
||||
const getCancelablePromise = async (run: IRun) =>
|
||||
await mock<PCancelable<IRun>>({
|
||||
then: jest
|
||||
.fn()
|
||||
.mockImplementation(async (onfulfilled) => await Promise.resolve(run).then(onfulfilled)),
|
||||
catch: jest
|
||||
.fn()
|
||||
.mockImplementation(async (onrejected) => await Promise.resolve(run).catch(onrejected)),
|
||||
finally: jest
|
||||
.fn()
|
||||
.mockImplementation(async (onfinally) => await Promise.resolve(run).finally(onfinally)),
|
||||
[Symbol.toStringTag]: 'PCancelable',
|
||||
});
|
||||
|
||||
const processRunExecutionData = jest.fn();
|
||||
|
||||
jest.mock('n8n-core', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('n8n-core'),
|
||||
WorkflowExecute: jest.fn().mockImplementation(() => ({
|
||||
processRunExecutionData: jest.fn().mockReturnValue(cancelablePromise),
|
||||
processRunExecutionData,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../workflow-helpers', () => ({
|
||||
...jest.requireActual('../workflow-helpers'),
|
||||
getDataLastExecutedNodeData: jest.fn().mockReturnValue({ data: { main: [] } }),
|
||||
}));
|
||||
|
||||
describe('WorkflowExecuteAdditionalData', () => {
|
||||
const variablesService = mockInstance(VariablesService);
|
||||
variablesService.getAllCached.mockResolvedValue([]);
|
||||
|
@ -95,17 +113,129 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
expect(eventService.emit).toHaveBeenCalledWith(eventName, payload);
|
||||
});
|
||||
|
||||
it('`executeWorkflow` should set subworkflow execution as running', async () => {
|
||||
const executionId = '123';
|
||||
workflowRepository.get.mockResolvedValue(mock<WorkflowEntity>({ id: executionId, nodes: [] }));
|
||||
activeExecutions.add.mockResolvedValue(executionId);
|
||||
describe('executeWorkflow', () => {
|
||||
const runWithData = getMockRun({ lastNodeOutput: [[{ json: { test: 1 } }]] });
|
||||
|
||||
await executeWorkflow(
|
||||
mock<IExecuteWorkflowInfo>(),
|
||||
mock<IWorkflowExecuteAdditionalData>(),
|
||||
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined }),
|
||||
);
|
||||
beforeEach(() => {
|
||||
workflowRepository.get.mockResolvedValue(
|
||||
mock<WorkflowEntity>({ id: EXECUTION_ID, nodes: [] }),
|
||||
);
|
||||
activeExecutions.add.mockResolvedValue(EXECUTION_ID);
|
||||
processRunExecutionData.mockReturnValue(getCancelablePromise(runWithData));
|
||||
});
|
||||
|
||||
expect(executionRepository.setRunning).toHaveBeenCalledWith(executionId);
|
||||
it('should execute workflow, return data and execution id', async () => {
|
||||
const response = await executeWorkflow(
|
||||
mock<IExecuteWorkflowInfo>(),
|
||||
mock<IWorkflowExecuteAdditionalData>(),
|
||||
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined, doNotWaitToFinish: false }),
|
||||
);
|
||||
|
||||
expect(response).toEqual({
|
||||
data: runWithData.data.resultData.runData[LAST_NODE_EXECUTED][0].data!.main,
|
||||
executionId: EXECUTION_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('should execute workflow, skip waiting', async () => {
|
||||
const response = await executeWorkflow(
|
||||
mock<IExecuteWorkflowInfo>(),
|
||||
mock<IWorkflowExecuteAdditionalData>(),
|
||||
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined, doNotWaitToFinish: true }),
|
||||
);
|
||||
|
||||
expect(response).toEqual({
|
||||
data: [null],
|
||||
executionId: EXECUTION_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set sub workflow execution as running', async () => {
|
||||
await executeWorkflow(
|
||||
mock<IExecuteWorkflowInfo>(),
|
||||
mock<IWorkflowExecuteAdditionalData>(),
|
||||
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined }),
|
||||
);
|
||||
|
||||
expect(executionRepository.setRunning).toHaveBeenCalledWith(EXECUTION_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunData', () => {
|
||||
it('should throw error to add trigger ndoe', async () => {
|
||||
const workflow = mock<IWorkflowBase>({
|
||||
id: '1',
|
||||
name: 'test',
|
||||
nodes: [],
|
||||
active: false,
|
||||
});
|
||||
await expect(getRunData(workflow)).rejects.toThrowError('Missing node to start execution');
|
||||
});
|
||||
|
||||
const workflow = mock<IWorkflowBase>({
|
||||
id: '1',
|
||||
name: 'test',
|
||||
nodes: [
|
||||
{
|
||||
type: 'n8n-nodes-base.executeWorkflowTrigger',
|
||||
},
|
||||
],
|
||||
active: false,
|
||||
});
|
||||
|
||||
it('should return default data', async () => {
|
||||
expect(await getRunData(workflow)).toEqual({
|
||||
executionData: {
|
||||
executionData: {
|
||||
contextData: {},
|
||||
metadata: {},
|
||||
nodeExecutionStack: [
|
||||
{
|
||||
data: { main: [[{ json: {} }]] },
|
||||
metadata: { parentExecution: undefined },
|
||||
node: workflow.nodes[0],
|
||||
source: null,
|
||||
},
|
||||
],
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
resultData: { runData: {} },
|
||||
startData: {},
|
||||
},
|
||||
executionMode: 'integrated',
|
||||
workflowData: workflow,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return run data with input data and metadata', async () => {
|
||||
const data = [{ json: { test: 1 } }];
|
||||
const parentExecution = {
|
||||
executionId: '123',
|
||||
workflowId: '567',
|
||||
};
|
||||
expect(await getRunData(workflow, data, parentExecution)).toEqual({
|
||||
executionData: {
|
||||
executionData: {
|
||||
contextData: {},
|
||||
metadata: {},
|
||||
nodeExecutionStack: [
|
||||
{
|
||||
data: { main: [data] },
|
||||
metadata: { parentExecution },
|
||||
node: workflow.nodes[0],
|
||||
source: null,
|
||||
},
|
||||
],
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
resultData: { runData: {} },
|
||||
startData: {},
|
||||
},
|
||||
executionMode: 'integrated',
|
||||
workflowData: workflow,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import type { Scope } from '@n8n/permissions';
|
||||
import type { Application } from 'express';
|
||||
import type { WorkflowExecute } from 'n8n-core';
|
||||
import type {
|
||||
ExecutionError,
|
||||
ICredentialDataDecryptedObject,
|
||||
|
@ -14,7 +13,6 @@ import type {
|
|||
ITelemetryTrackProperties,
|
||||
IWorkflowBase,
|
||||
CredentialLoadingDetails,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
ExecutionStatus,
|
||||
ExecutionSummary,
|
||||
|
@ -300,12 +298,6 @@ export interface IWorkflowErrorData {
|
|||
};
|
||||
}
|
||||
|
||||
export interface IWorkflowExecuteProcess {
|
||||
startedAt: Date;
|
||||
workflow: Workflow;
|
||||
workflowExecute: WorkflowExecute;
|
||||
}
|
||||
|
||||
export interface IWorkflowStatisticsDataLoaded {
|
||||
dataLoaded: boolean;
|
||||
}
|
||||
|
|
|
@ -36,6 +36,8 @@ import type {
|
|||
ExecuteWorkflowOptions,
|
||||
IWorkflowExecutionDataProcess,
|
||||
EnvProviderState,
|
||||
ExecuteWorkflowData,
|
||||
RelatedExecution,
|
||||
} from 'n8n-workflow';
|
||||
import { Container } from 'typedi';
|
||||
|
||||
|
@ -45,11 +47,7 @@ import { CredentialsHelper } from '@/credentials-helper';
|
|||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||
import type { AiEventMap, AiEventPayload } from '@/events/maps/ai.event-map';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import type {
|
||||
IWorkflowExecuteProcess,
|
||||
IWorkflowErrorData,
|
||||
UpdateExecutionPayload,
|
||||
} from '@/interfaces';
|
||||
import type { IWorkflowErrorData, UpdateExecutionPayload } from '@/interfaces';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { Push } from '@/push';
|
||||
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
|
||||
|
@ -650,6 +648,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
|
|||
export async function getRunData(
|
||||
workflowData: IWorkflowBase,
|
||||
inputData?: INodeExecutionData[],
|
||||
parentExecution?: RelatedExecution,
|
||||
): Promise<IWorkflowExecutionDataProcess> {
|
||||
const mode = 'integrated';
|
||||
|
||||
|
@ -669,6 +668,7 @@ export async function getRunData(
|
|||
data: {
|
||||
main: [inputData],
|
||||
},
|
||||
metadata: { parentExecution },
|
||||
source: null,
|
||||
});
|
||||
|
||||
|
@ -740,7 +740,41 @@ export async function executeWorkflow(
|
|||
workflowInfo: IExecuteWorkflowInfo,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
options: ExecuteWorkflowOptions,
|
||||
): Promise<Array<INodeExecutionData[] | null> | IWorkflowExecuteProcess> {
|
||||
): Promise<ExecuteWorkflowData> {
|
||||
const activeExecutions = Container.get(ActiveExecutions);
|
||||
|
||||
const workflowData =
|
||||
options.loadedWorkflowData ??
|
||||
(await getWorkflowData(workflowInfo, options.parentWorkflowId, options.parentWorkflowSettings));
|
||||
|
||||
const runData =
|
||||
options.loadedRunData ??
|
||||
(await getRunData(workflowData, options.inputData, options.parentExecution));
|
||||
|
||||
const executionId = await activeExecutions.add(runData);
|
||||
|
||||
const executionPromise = startExecution(
|
||||
additionalData,
|
||||
options,
|
||||
executionId,
|
||||
runData,
|
||||
workflowData,
|
||||
);
|
||||
|
||||
if (options.doNotWaitToFinish) {
|
||||
return { executionId, data: [null] };
|
||||
}
|
||||
|
||||
return await executionPromise;
|
||||
}
|
||||
|
||||
async function startExecution(
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
options: ExecuteWorkflowOptions,
|
||||
executionId: string,
|
||||
runData: IWorkflowExecutionDataProcess,
|
||||
workflowData: IWorkflowBase,
|
||||
): Promise<ExecuteWorkflowData> {
|
||||
const externalHooks = Container.get(ExternalHooks);
|
||||
await externalHooks.init();
|
||||
|
||||
|
@ -749,10 +783,6 @@ export async function executeWorkflow(
|
|||
const eventService = Container.get(EventService);
|
||||
const executionRepository = Container.get(ExecutionRepository);
|
||||
|
||||
const workflowData =
|
||||
options.loadedWorkflowData ??
|
||||
(await getWorkflowData(workflowInfo, options.parentWorkflowId, options.parentWorkflowSettings));
|
||||
|
||||
const workflowName = workflowData ? workflowData.name : undefined;
|
||||
const workflow = new Workflow({
|
||||
id: workflowData.id,
|
||||
|
@ -765,10 +795,6 @@ export async function executeWorkflow(
|
|||
settings: workflowData.settings,
|
||||
});
|
||||
|
||||
const runData = options.loadedRunData ?? (await getRunData(workflowData, options.inputData));
|
||||
|
||||
const executionId = await activeExecutions.add(runData);
|
||||
|
||||
/**
|
||||
* A subworkflow execution in queue mode is not enqueued, but rather runs in the
|
||||
* same worker process as the parent execution. Hence ensure the subworkflow
|
||||
|
@ -890,7 +916,10 @@ export async function executeWorkflow(
|
|||
|
||||
activeExecutions.finalizeExecution(executionId, data);
|
||||
const returnData = WorkflowHelpers.getDataLastExecutedNodeData(data);
|
||||
return returnData!.data!.main;
|
||||
return {
|
||||
executionId,
|
||||
data: returnData!.data!.main,
|
||||
};
|
||||
}
|
||||
activeExecutions.finalizeExecution(executionId, data);
|
||||
|
||||
|
|
|
@ -20,7 +20,8 @@
|
|||
"lint": "eslint . --quiet",
|
||||
"lintfix": "eslint . --fix",
|
||||
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\"",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"test:dev": "jest --watch"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
|
|
@ -39,6 +39,7 @@ import type {
|
|||
BinaryHelperFunctions,
|
||||
CloseFunction,
|
||||
ContextType,
|
||||
ExecuteWorkflowData,
|
||||
FieldType,
|
||||
FileSystemHelperFunctions,
|
||||
FunctionsBase,
|
||||
|
@ -78,6 +79,7 @@ import type {
|
|||
IRunExecutionData,
|
||||
ITaskData,
|
||||
ITaskDataConnections,
|
||||
ITaskMetadata,
|
||||
ITriggerFunctions,
|
||||
IWebhookData,
|
||||
IWebhookDescription,
|
||||
|
@ -109,6 +111,7 @@ import type {
|
|||
ISupplyDataFunctions,
|
||||
WebhookType,
|
||||
SchedulingFunctions,
|
||||
RelatedExecution,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
NodeConnectionType,
|
||||
|
@ -2721,6 +2724,7 @@ const addExecutionDataFunctions = async (
|
|||
sourceNodeName: string,
|
||||
sourceNodeRunIndex: number,
|
||||
currentNodeRunIndex: number,
|
||||
metadata?: ITaskMetadata,
|
||||
): Promise<void> => {
|
||||
if (connectionType === NodeConnectionType.Main) {
|
||||
throw new ApplicationError('Setting type is not supported for main connection', {
|
||||
|
@ -2746,6 +2750,7 @@ const addExecutionDataFunctions = async (
|
|||
if (taskData === undefined) {
|
||||
return;
|
||||
}
|
||||
taskData.metadata = metadata;
|
||||
}
|
||||
taskData = taskData!;
|
||||
|
||||
|
@ -3622,6 +3627,12 @@ export function getExecuteFunctions(
|
|||
itemIndex,
|
||||
),
|
||||
getExecuteData: () => executeData,
|
||||
setMetadata: (metadata: ITaskMetadata): void => {
|
||||
executeData.metadata = {
|
||||
...(executeData.metadata ?? {}),
|
||||
...metadata,
|
||||
};
|
||||
},
|
||||
continueOnFail: () => {
|
||||
return continueOnFail(node);
|
||||
},
|
||||
|
@ -3643,23 +3654,28 @@ export function getExecuteFunctions(
|
|||
workflowInfo: IExecuteWorkflowInfo,
|
||||
inputData?: INodeExecutionData[],
|
||||
parentCallbackManager?: CallbackManager,
|
||||
): Promise<any> {
|
||||
options?: {
|
||||
doNotWaitToFinish?: boolean;
|
||||
parentExecution?: RelatedExecution;
|
||||
},
|
||||
): Promise<ExecuteWorkflowData> {
|
||||
return await additionalData
|
||||
.executeWorkflow(workflowInfo, additionalData, {
|
||||
...options,
|
||||
parentWorkflowId: workflow.id?.toString(),
|
||||
inputData,
|
||||
parentWorkflowSettings: workflow.settings,
|
||||
node,
|
||||
parentCallbackManager,
|
||||
})
|
||||
.then(
|
||||
async (result) =>
|
||||
await Container.get(BinaryDataService).duplicateBinaryData(
|
||||
workflow.id,
|
||||
additionalData.executionId!,
|
||||
result,
|
||||
),
|
||||
);
|
||||
.then(async (result) => {
|
||||
const data = await Container.get(BinaryDataService).duplicateBinaryData(
|
||||
workflow.id,
|
||||
additionalData.executionId!,
|
||||
result.data,
|
||||
);
|
||||
return { ...result, data };
|
||||
});
|
||||
},
|
||||
getContext(type: ContextType): IContextObject {
|
||||
return NodeHelpers.getContext(runExecutionData, type, node);
|
||||
|
@ -3853,6 +3869,7 @@ export function getExecuteFunctions(
|
|||
connectionType: NodeConnectionType,
|
||||
currentNodeRunIndex: number,
|
||||
data: INodeExecutionData[][] | ExecutionBaseError,
|
||||
metadata?: ITaskMetadata,
|
||||
): void {
|
||||
addExecutionDataFunctions(
|
||||
'output',
|
||||
|
@ -3864,6 +3881,7 @@ export function getExecuteFunctions(
|
|||
node.name,
|
||||
runIndex,
|
||||
currentNodeRunIndex,
|
||||
metadata,
|
||||
).catch((error) => {
|
||||
Logger.warn(
|
||||
`There was a problem logging output data of node "${this.getNode().name}": ${
|
||||
|
@ -3972,7 +3990,11 @@ export function getSupplyDataFunctions(
|
|||
workflowInfo: IExecuteWorkflowInfo,
|
||||
inputData?: INodeExecutionData[],
|
||||
parentCallbackManager?: CallbackManager,
|
||||
) =>
|
||||
options?: {
|
||||
doNotWaitToFinish?: boolean;
|
||||
parentExecution?: RelatedExecution;
|
||||
},
|
||||
): Promise<ExecuteWorkflowData> =>
|
||||
await additionalData
|
||||
.executeWorkflow(workflowInfo, additionalData, {
|
||||
parentWorkflowId: workflow.id?.toString(),
|
||||
|
@ -3980,15 +4002,16 @@ export function getSupplyDataFunctions(
|
|||
parentWorkflowSettings: workflow.settings,
|
||||
node,
|
||||
parentCallbackManager,
|
||||
...options,
|
||||
})
|
||||
.then(
|
||||
async (result) =>
|
||||
await Container.get(BinaryDataService).duplicateBinaryData(
|
||||
workflow.id,
|
||||
additionalData.executionId!,
|
||||
result,
|
||||
),
|
||||
),
|
||||
.then(async (result) => {
|
||||
const data = await Container.get(BinaryDataService).duplicateBinaryData(
|
||||
workflow.id,
|
||||
additionalData.executionId!,
|
||||
result.data,
|
||||
);
|
||||
return { ...result, data };
|
||||
}),
|
||||
getNodeOutputs() {
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
return NodeHelpers.getNodeOutputs(workflow, node, nodeType.description).map((output) => {
|
||||
|
@ -4143,6 +4166,7 @@ export function getSupplyDataFunctions(
|
|||
connectionType: NodeConnectionType,
|
||||
currentNodeRunIndex: number,
|
||||
data: INodeExecutionData[][],
|
||||
metadata?: ITaskMetadata,
|
||||
): void {
|
||||
addExecutionDataFunctions(
|
||||
'output',
|
||||
|
@ -4154,6 +4178,7 @@ export function getSupplyDataFunctions(
|
|||
node.name,
|
||||
runIndex,
|
||||
currentNodeRunIndex,
|
||||
metadata,
|
||||
).catch((error) => {
|
||||
Logger.warn(
|
||||
`There was a problem logging output data of node "${this.getNode().name}": ${
|
||||
|
|
|
@ -408,7 +408,10 @@ export class WorkflowExecute {
|
|||
let metaRunData: ITaskMetadata;
|
||||
for (const nodeName of Object.keys(metadata)) {
|
||||
for ([index, metaRunData] of metadata[nodeName].entries()) {
|
||||
runData[nodeName][index].metadata = metaRunData;
|
||||
runData[nodeName][index].metadata = {
|
||||
...(runData[nodeName][index].metadata ?? {}),
|
||||
...metaRunData,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1448,6 +1451,7 @@ export class WorkflowExecute {
|
|||
startTime,
|
||||
executionTime: new Date().getTime() - startTime,
|
||||
source: !executionData.source ? [] : executionData.source.main,
|
||||
metadata: executionData.metadata,
|
||||
executionStatus: 'success',
|
||||
};
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ import type {
|
|||
IContextObject,
|
||||
ICredentialDataDecryptedObject,
|
||||
ISourceData,
|
||||
ITaskMetadata,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, NodeHelpers } from 'n8n-workflow';
|
||||
|
||||
|
@ -298,4 +299,33 @@ describe('ExecuteSingleContext', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMetadata', () => {
|
||||
it('sets metadata on execution data', () => {
|
||||
const context = new ExecuteSingleContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
itemIndex,
|
||||
executeData,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
const metadata: ITaskMetadata = {
|
||||
subExecution: {
|
||||
workflowId: '123',
|
||||
executionId: 'xyz',
|
||||
},
|
||||
};
|
||||
|
||||
expect(context.getExecuteData().metadata?.subExecution).toEqual(undefined);
|
||||
context.setMetadata(metadata);
|
||||
expect(context.getExecuteData().metadata?.subExecution).toEqual(metadata.subExecution);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,6 +13,7 @@ import type {
|
|||
ContextType,
|
||||
AiEvent,
|
||||
ISourceData,
|
||||
ITaskMetadata,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
|
@ -85,6 +86,13 @@ export class ExecuteSingleContext extends NodeExecutionContext implements IExecu
|
|||
this.abortSignal?.addEventListener('abort', fn);
|
||||
}
|
||||
|
||||
setMetadata(metadata: ITaskMetadata): void {
|
||||
this.executeData.metadata = {
|
||||
...(this.executeData.metadata ?? {}),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
continueOnFail() {
|
||||
return continueOnFail(this.node);
|
||||
}
|
||||
|
|
|
@ -182,6 +182,10 @@ export interface IAiDataContent {
|
|||
metadata: {
|
||||
executionTime: number;
|
||||
startTime: number;
|
||||
subExecution?: {
|
||||
workflowId: string;
|
||||
executionId: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -202,6 +206,10 @@ export interface ITableData {
|
|||
columns: string[];
|
||||
data: GenericValue[][];
|
||||
hasJson: { [key: string]: boolean };
|
||||
metadata: {
|
||||
hasExecutionIds: boolean;
|
||||
data: Array<INodeExecutionData['metadata'] | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
// Simple version of n8n-workflow.Workflow
|
||||
|
|
|
@ -35,7 +35,7 @@ const onDragStart = () => {
|
|||
@dragend="onDragEnd"
|
||||
>
|
||||
<template #default="{ isDragging }">
|
||||
<div :class="{ [$style.dragButton]: true }">
|
||||
<div :class="{ [$style.dragButton]: true }" data-test-id="panel-drag-button">
|
||||
<span
|
||||
v-if="canMoveLeft"
|
||||
:class="{ [$style.leftArrow]: true, [$style.visible]: isDragging }"
|
||||
|
|
|
@ -8,10 +8,17 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import type { INodeExecutionData } from 'n8n-workflow';
|
||||
import type { INodeExecutionData, ITaskData, ITaskMetadata } from 'n8n-workflow';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { useNodeTypesStore } from '../stores/nodeTypes.store';
|
||||
|
||||
const MOCK_EXECUTION_URL = 'execution.url/123';
|
||||
|
||||
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = vi.hoisted(() => ({
|
||||
trackOpeningRelatedExecution: vi.fn(),
|
||||
resolveRelatedExecutionUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
return {
|
||||
useRouter: () => ({}),
|
||||
|
@ -20,6 +27,13 @@ vi.mock('vue-router', () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useExecutionHelpers', () => ({
|
||||
useExecutionHelpers: () => ({
|
||||
trackOpeningRelatedExecution,
|
||||
resolveRelatedExecutionUrl,
|
||||
}),
|
||||
}));
|
||||
|
||||
const nodes = [
|
||||
{
|
||||
id: '1',
|
||||
|
@ -32,50 +46,55 @@ const nodes = [
|
|||
] as INodeUi[];
|
||||
|
||||
describe('RunData', () => {
|
||||
beforeAll(() => {
|
||||
resolveRelatedExecutionUrl.mockReturnValue('execution.url/123');
|
||||
});
|
||||
|
||||
it("should render pin button in output panel disabled when there's binary data", () => {
|
||||
const { getByTestId } = render(
|
||||
[
|
||||
const { getByTestId } = render({
|
||||
defaultRunItems: [
|
||||
{
|
||||
json: {},
|
||||
binary: {
|
||||
data: {
|
||||
fileName: 'test.xyz',
|
||||
mimeType: 'application/octet-stream',
|
||||
data: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
'binary',
|
||||
);
|
||||
displayMode: 'binary',
|
||||
});
|
||||
|
||||
expect(getByTestId('ndv-pin-data')).toBeInTheDocument();
|
||||
expect(getByTestId('ndv-pin-data')).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it("should not render pin button in input panel when there's binary data", () => {
|
||||
const { queryByTestId } = render(
|
||||
[
|
||||
const { queryByTestId } = render({
|
||||
defaultRunItems: [
|
||||
{
|
||||
json: {},
|
||||
binary: {
|
||||
data: {
|
||||
fileName: 'test.xyz',
|
||||
mimeType: 'application/octet-stream',
|
||||
data: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
'binary',
|
||||
undefined,
|
||||
'input',
|
||||
);
|
||||
displayMode: 'binary',
|
||||
paneType: 'input',
|
||||
});
|
||||
|
||||
expect(queryByTestId('ndv-pin-data')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render data correctly even when "item.json" has another "json" key', async () => {
|
||||
const { getByText, getAllByTestId, getByTestId } = render(
|
||||
[
|
||||
const { getByText, getAllByTestId, getByTestId } = render({
|
||||
defaultRunItems: [
|
||||
{
|
||||
json: {
|
||||
id: 1,
|
||||
|
@ -95,8 +114,8 @@ describe('RunData', () => {
|
|||
},
|
||||
},
|
||||
],
|
||||
'schema',
|
||||
);
|
||||
displayMode: 'schema',
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('ndv-pin-data'));
|
||||
await waitFor(() => getAllByTestId('run-data-schema-item'), { timeout: 1000 });
|
||||
|
@ -105,8 +124,8 @@ describe('RunData', () => {
|
|||
});
|
||||
|
||||
it('should render view and download buttons for PDFs', async () => {
|
||||
const { getByTestId } = render(
|
||||
[
|
||||
const { getByTestId } = render({
|
||||
defaultRunItems: [
|
||||
{
|
||||
json: {},
|
||||
binary: {
|
||||
|
@ -114,12 +133,13 @@ describe('RunData', () => {
|
|||
fileName: 'test.pdf',
|
||||
fileType: 'pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
'binary',
|
||||
);
|
||||
displayMode: 'binary',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('ndv-view-binary-data')).toBeInTheDocument();
|
||||
|
@ -129,20 +149,21 @@ describe('RunData', () => {
|
|||
});
|
||||
|
||||
it('should not render a view button for unknown content-type', async () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
[
|
||||
const { getByTestId, queryByTestId } = render({
|
||||
defaultRunItems: [
|
||||
{
|
||||
json: {},
|
||||
binary: {
|
||||
data: {
|
||||
fileName: 'test.xyz',
|
||||
mimeType: 'application/octet-stream',
|
||||
data: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
'binary',
|
||||
);
|
||||
displayMode: 'binary',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('ndv-view-binary-data')).not.toBeInTheDocument();
|
||||
|
@ -152,25 +173,32 @@ describe('RunData', () => {
|
|||
});
|
||||
|
||||
it('should not render pin data button when there is no output data', async () => {
|
||||
const { queryByTestId } = render([], 'table');
|
||||
const { queryByTestId } = render({ defaultRunItems: [], displayMode: 'table' });
|
||||
expect(queryByTestId('ndv-pin-data')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable pin data button when data is pinned', async () => {
|
||||
const { getByTestId } = render([], 'table', [{ json: { name: 'Test' } }]);
|
||||
const { getByTestId } = render({
|
||||
defaultRunItems: [],
|
||||
displayMode: 'table',
|
||||
pinnedData: [{ json: { name: 'Test' } }],
|
||||
});
|
||||
const pinDataButton = getByTestId('ndv-pin-data');
|
||||
expect(pinDataButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable pin data button when data is not pinned', async () => {
|
||||
const { getByTestId } = render([{ json: { name: 'Test' } }], 'table');
|
||||
const { getByTestId } = render({
|
||||
defaultRunItems: [{ json: { name: 'Test' } }],
|
||||
displayMode: 'table',
|
||||
});
|
||||
const pinDataButton = getByTestId('ndv-pin-data');
|
||||
expect(pinDataButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('should not render pagination on binary tab', async () => {
|
||||
const { queryByTestId } = render(
|
||||
Array.from({ length: 11 }).map((_, i) => ({
|
||||
const { queryByTestId } = render({
|
||||
defaultRunItems: Array.from({ length: 11 }).map((_, i) => ({
|
||||
json: {
|
||||
data: {
|
||||
id: i,
|
||||
|
@ -180,17 +208,19 @@ describe('RunData', () => {
|
|||
binary: {
|
||||
data: {
|
||||
a: 'b',
|
||||
data: '',
|
||||
mimeType: '',
|
||||
},
|
||||
},
|
||||
})),
|
||||
'binary',
|
||||
);
|
||||
displayMode: 'binary',
|
||||
});
|
||||
expect(queryByTestId('ndv-data-pagination')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render pagination with binary data on non-binary tab', async () => {
|
||||
const { getByTestId } = render(
|
||||
Array.from({ length: 11 }).map((_, i) => ({
|
||||
const { getByTestId } = render({
|
||||
defaultRunItems: Array.from({ length: 11 }).map((_, i) => ({
|
||||
json: {
|
||||
data: {
|
||||
id: i,
|
||||
|
@ -200,20 +230,177 @@ describe('RunData', () => {
|
|||
binary: {
|
||||
data: {
|
||||
a: 'b',
|
||||
data: '',
|
||||
mimeType: '',
|
||||
},
|
||||
},
|
||||
})),
|
||||
'json',
|
||||
);
|
||||
displayMode: 'json',
|
||||
});
|
||||
expect(getByTestId('ndv-data-pagination')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const render = (
|
||||
outputData: unknown[],
|
||||
displayMode: IRunDataDisplayMode,
|
||||
pinnedData?: INodeExecutionData[],
|
||||
paneType: NodePanelType = 'output',
|
||||
) => {
|
||||
it('should render sub-execution link in header', async () => {
|
||||
const metadata: ITaskMetadata = {
|
||||
subExecution: {
|
||||
workflowId: 'xyz',
|
||||
executionId: '123',
|
||||
},
|
||||
subExecutionsCount: 1,
|
||||
};
|
||||
const { getByTestId } = render({
|
||||
defaultRunItems: [
|
||||
{
|
||||
json: {},
|
||||
},
|
||||
],
|
||||
displayMode: 'table',
|
||||
paneType: 'output',
|
||||
metadata,
|
||||
});
|
||||
|
||||
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
||||
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123');
|
||||
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
|
||||
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
|
||||
|
||||
expect(getByTestId('ndv-items-count')).toHaveTextContent('1 item, 1 sub-execution');
|
||||
|
||||
getByTestId('related-execution-link').click();
|
||||
expect(trackOpeningRelatedExecution).toHaveBeenCalledWith(metadata, 'table');
|
||||
});
|
||||
|
||||
it('should render parent-execution link in header', async () => {
|
||||
const metadata: ITaskMetadata = {
|
||||
parentExecution: {
|
||||
workflowId: 'xyz',
|
||||
executionId: '123',
|
||||
},
|
||||
};
|
||||
const { getByTestId } = render({
|
||||
defaultRunItems: [
|
||||
{
|
||||
json: {},
|
||||
},
|
||||
],
|
||||
displayMode: 'table',
|
||||
paneType: 'output',
|
||||
metadata,
|
||||
});
|
||||
|
||||
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
||||
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Parent Execution 123');
|
||||
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
|
||||
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
|
||||
|
||||
expect(getByTestId('ndv-items-count')).toHaveTextContent('1 item');
|
||||
|
||||
getByTestId('related-execution-link').click();
|
||||
expect(trackOpeningRelatedExecution).toHaveBeenCalledWith(metadata, 'table');
|
||||
});
|
||||
|
||||
it('should render sub-execution link in header with multiple items', async () => {
|
||||
const metadata: ITaskMetadata = {
|
||||
subExecution: {
|
||||
workflowId: 'xyz',
|
||||
executionId: '123',
|
||||
},
|
||||
subExecutionsCount: 3,
|
||||
};
|
||||
const { getByTestId } = render({
|
||||
defaultRunItems: [
|
||||
{
|
||||
json: {},
|
||||
},
|
||||
{
|
||||
json: {},
|
||||
},
|
||||
],
|
||||
displayMode: 'json',
|
||||
paneType: 'output',
|
||||
metadata,
|
||||
});
|
||||
|
||||
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
||||
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123');
|
||||
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
|
||||
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
|
||||
|
||||
expect(getByTestId('ndv-items-count')).toHaveTextContent('2 items, 3 sub-executions');
|
||||
|
||||
getByTestId('related-execution-link').click();
|
||||
expect(trackOpeningRelatedExecution).toHaveBeenCalledWith(metadata, 'json');
|
||||
});
|
||||
|
||||
it('should render sub-execution link in header with multiple runs', async () => {
|
||||
const metadata: ITaskMetadata = {
|
||||
subExecution: {
|
||||
workflowId: 'xyz',
|
||||
executionId: '123',
|
||||
},
|
||||
subExecutionsCount: 3,
|
||||
};
|
||||
const { getByTestId, queryByTestId } = render({
|
||||
runs: [
|
||||
{
|
||||
startTime: new Date().getTime(),
|
||||
executionTime: new Date().getTime(),
|
||||
data: {
|
||||
main: [[{ json: {} }]],
|
||||
},
|
||||
source: [null],
|
||||
metadata,
|
||||
},
|
||||
{
|
||||
startTime: new Date().getTime(),
|
||||
executionTime: new Date().getTime(),
|
||||
data: {
|
||||
main: [[{ json: {} }]],
|
||||
},
|
||||
source: [null],
|
||||
metadata,
|
||||
},
|
||||
],
|
||||
displayMode: 'json',
|
||||
paneType: 'output',
|
||||
metadata,
|
||||
});
|
||||
|
||||
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
||||
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123');
|
||||
|
||||
expect(queryByTestId('ndv-items-count')).not.toBeInTheDocument();
|
||||
expect(getByTestId('run-selector')).toBeInTheDocument();
|
||||
|
||||
getByTestId('related-execution-link').click();
|
||||
expect(trackOpeningRelatedExecution).toHaveBeenCalledWith(metadata, 'json');
|
||||
});
|
||||
|
||||
const render = ({
|
||||
defaultRunItems,
|
||||
displayMode,
|
||||
pinnedData,
|
||||
paneType = 'output',
|
||||
metadata,
|
||||
runs,
|
||||
}: {
|
||||
defaultRunItems?: INodeExecutionData[];
|
||||
displayMode: IRunDataDisplayMode;
|
||||
pinnedData?: INodeExecutionData[];
|
||||
paneType?: NodePanelType;
|
||||
metadata?: ITaskMetadata;
|
||||
runs?: ITaskData[];
|
||||
}) => {
|
||||
const defaultRun: ITaskData = {
|
||||
startTime: new Date().getTime(),
|
||||
executionTime: new Date().getTime(),
|
||||
data: {
|
||||
main: [defaultRunItems ?? [{ json: {} }]],
|
||||
},
|
||||
source: [null],
|
||||
metadata,
|
||||
};
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
|
@ -246,16 +433,7 @@ describe('RunData', () => {
|
|||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
'Test Node': [
|
||||
{
|
||||
startTime: new Date().getTime(),
|
||||
executionTime: new Date().getTime(),
|
||||
data: {
|
||||
main: [outputData],
|
||||
},
|
||||
source: [null],
|
||||
},
|
||||
],
|
||||
'Test Node': runs ?? [defaultRun],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -9,6 +9,7 @@ import type {
|
|||
INodeOutputConfiguration,
|
||||
IRunData,
|
||||
IRunExecutionData,
|
||||
ITaskMetadata,
|
||||
NodeError,
|
||||
NodeHint,
|
||||
Workflow,
|
||||
|
@ -77,6 +78,7 @@ import {
|
|||
} from 'n8n-design-system';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
|
||||
const LazyRunDataTable = defineAsyncComponent(
|
||||
async () => await import('@/components/RunDataTable.vue'),
|
||||
|
@ -180,6 +182,7 @@ const nodeHelpers = useNodeHelpers();
|
|||
const externalHooks = useExternalHooks();
|
||||
const telemetry = useTelemetry();
|
||||
const i18n = useI18n();
|
||||
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = useExecutionHelpers();
|
||||
|
||||
const node = toRef(props, 'node');
|
||||
|
||||
|
@ -315,7 +318,9 @@ const workflowRunData = computed(() => {
|
|||
}
|
||||
return null;
|
||||
});
|
||||
const dataCount = computed(() => getDataCount(props.runIndex, currentOutputIndex.value));
|
||||
const dataCount = computed(() =>
|
||||
getDataCount(props.runIndex, currentOutputIndex.value, connectionType.value),
|
||||
);
|
||||
|
||||
const unfilteredDataCount = computed(() =>
|
||||
pinnedData.data.value ? pinnedData.data.value.length : rawInputData.value.length,
|
||||
|
@ -506,6 +511,28 @@ const pinButtonDisabled = computed(
|
|||
readOnlyEnv.value,
|
||||
);
|
||||
|
||||
const activeTaskMetadata = computed((): ITaskMetadata | null => {
|
||||
if (!node.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return workflowRunData.value?.[node.value.name]?.[props.runIndex]?.metadata ?? null;
|
||||
});
|
||||
|
||||
const hasReleatedExectuion = computed((): boolean => {
|
||||
return Boolean(
|
||||
activeTaskMetadata.value?.subExecution || activeTaskMetadata.value?.parentExecution,
|
||||
);
|
||||
});
|
||||
|
||||
const hasInputOverwrite = computed((): boolean => {
|
||||
if (!node.value) {
|
||||
return false;
|
||||
}
|
||||
const taskData = nodeHelpers.getNodeTaskData(node.value, props.runIndex);
|
||||
return Boolean(taskData?.inputOverride);
|
||||
});
|
||||
|
||||
watch(node, (newNode, prevNode) => {
|
||||
if (newNode?.id === prevNode?.id) return;
|
||||
init();
|
||||
|
@ -953,6 +980,10 @@ function onDisplayModeChange(newDisplayMode: IRunDataDisplayMode) {
|
|||
}
|
||||
|
||||
function getRunLabel(option: number) {
|
||||
if (!node.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
let itemsCount = 0;
|
||||
for (let i = 0; i <= maxOutputIndex.value; i++) {
|
||||
itemsCount += getPinDataOrLiveData(getRawInputData(option - 1, i)).length;
|
||||
|
@ -961,7 +992,18 @@ function getRunLabel(option: number) {
|
|||
adjustToNumber: itemsCount,
|
||||
interpolate: { count: itemsCount },
|
||||
});
|
||||
const itemsLabel = itemsCount > 0 ? ` (${items})` : '';
|
||||
|
||||
const metadata = workflowRunData.value?.[node.value.name]?.[option - 1]?.metadata ?? null;
|
||||
const subexecutions = metadata?.subExecutionsCount
|
||||
? i18n.baseText('ndv.output.andSubExecutions', {
|
||||
adjustToNumber: metadata.subExecutionsCount,
|
||||
interpolate: {
|
||||
count: metadata.subExecutionsCount,
|
||||
},
|
||||
})
|
||||
: '';
|
||||
|
||||
const itemsLabel = itemsCount > 0 ? ` (${items}${subexecutions})` : '';
|
||||
return option + i18n.baseText('ndv.output.of') + (maxRunIndex.value + 1) + itemsLabel;
|
||||
}
|
||||
|
||||
|
@ -1185,6 +1227,22 @@ function onSearchClear() {
|
|||
document.dispatchEvent(new KeyboardEvent('keyup', { key: '/' }));
|
||||
}
|
||||
|
||||
function getExecutionLinkLabel(task: ITaskMetadata): string | undefined {
|
||||
if (task.parentExecution) {
|
||||
return i18n.baseText('runData.openParentExecution', {
|
||||
interpolate: { id: task.parentExecution.executionId },
|
||||
});
|
||||
}
|
||||
|
||||
if (task.subExecution) {
|
||||
return i18n.baseText('runData.openSubExecution', {
|
||||
interpolate: { id: task.subExecution.executionId },
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
defineExpose({ enterEditMode });
|
||||
</script>
|
||||
|
||||
|
@ -1311,42 +1369,58 @@ defineExpose({ enterEditMode });
|
|||
v-show="!editMode.enabled"
|
||||
:class="$style.runSelector"
|
||||
>
|
||||
<slot v-if="inputSelectLocation === 'runs'" name="input-select"></slot>
|
||||
<div :class="$style.runSelectorInner">
|
||||
<slot v-if="inputSelectLocation === 'runs'" name="input-select"></slot>
|
||||
|
||||
<N8nSelect
|
||||
:model-value="runIndex"
|
||||
:class="$style.runSelectorInner"
|
||||
size="small"
|
||||
teleported
|
||||
data-test-id="run-selector"
|
||||
@update:model-value="onRunIndexChange"
|
||||
@click.stop
|
||||
>
|
||||
<template #prepend>{{ $locale.baseText('ndv.output.run') }}</template>
|
||||
<N8nOption
|
||||
v-for="option in maxRunIndex + 1"
|
||||
:key="option"
|
||||
:label="getRunLabel(option)"
|
||||
:value="option - 1"
|
||||
></N8nOption>
|
||||
</N8nSelect>
|
||||
|
||||
<N8nTooltip v-if="canLinkRuns" placement="right">
|
||||
<template #content>
|
||||
{{ $locale.baseText(linkedRuns ? 'runData.unlinking.hint' : 'runData.linking.hint') }}
|
||||
</template>
|
||||
<N8nIconButton
|
||||
:icon="linkedRuns ? 'unlink' : 'link'"
|
||||
class="linkRun"
|
||||
text
|
||||
type="tertiary"
|
||||
<N8nSelect
|
||||
:model-value="runIndex"
|
||||
:class="$style.runSelectorSelect"
|
||||
size="small"
|
||||
data-test-id="link-run"
|
||||
@click="toggleLinkRuns"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
teleported
|
||||
data-test-id="run-selector"
|
||||
@update:model-value="onRunIndexChange"
|
||||
@click.stop
|
||||
>
|
||||
<template #prepend>{{ $locale.baseText('ndv.output.run') }}</template>
|
||||
<N8nOption
|
||||
v-for="option in maxRunIndex + 1"
|
||||
:key="option"
|
||||
:label="getRunLabel(option)"
|
||||
:value="option - 1"
|
||||
></N8nOption>
|
||||
</N8nSelect>
|
||||
|
||||
<slot name="run-info"></slot>
|
||||
<N8nTooltip v-if="canLinkRuns" placement="right">
|
||||
<template #content>
|
||||
{{ $locale.baseText(linkedRuns ? 'runData.unlinking.hint' : 'runData.linking.hint') }}
|
||||
</template>
|
||||
<N8nIconButton
|
||||
:icon="linkedRuns ? 'unlink' : 'link'"
|
||||
class="linkRun"
|
||||
text
|
||||
type="tertiary"
|
||||
size="small"
|
||||
data-test-id="link-run"
|
||||
@click="toggleLinkRuns"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
|
||||
<slot name="run-info"></slot>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="
|
||||
activeTaskMetadata && hasReleatedExectuion && !(paneType === 'input' && hasInputOverwrite)
|
||||
"
|
||||
:class="$style.relatedExecutionInfo"
|
||||
data-test-id="related-execution-link"
|
||||
:href="resolveRelatedExecutionUrl(activeTaskMetadata)"
|
||||
target="_blank"
|
||||
@click.stop="trackOpeningRelatedExecution(activeTaskMetadata, displayMode)"
|
||||
>
|
||||
<N8nIcon icon="external-link-alt" size="xsmall" />
|
||||
{{ getExecutionLinkLabel(activeTaskMetadata) }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<slot v-if="!displaysMultipleNodes" name="before-data" />
|
||||
|
@ -1400,13 +1474,37 @@ defineExpose({ enterEditMode });
|
|||
}}
|
||||
</N8nText>
|
||||
<N8nText v-else :class="$style.itemsText">
|
||||
{{
|
||||
$locale.baseText('ndv.output.items', {
|
||||
adjustToNumber: dataCount,
|
||||
interpolate: { count: dataCount },
|
||||
})
|
||||
}}
|
||||
<span>
|
||||
{{
|
||||
$locale.baseText('ndv.output.items', {
|
||||
adjustToNumber: dataCount,
|
||||
interpolate: { count: dataCount },
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-if="activeTaskMetadata?.subExecutionsCount">
|
||||
{{
|
||||
$locale.baseText('ndv.output.andSubExecutions', {
|
||||
adjustToNumber: activeTaskMetadata.subExecutionsCount,
|
||||
interpolate: { count: activeTaskMetadata.subExecutionsCount },
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</N8nText>
|
||||
|
||||
<a
|
||||
v-if="
|
||||
activeTaskMetadata && hasReleatedExectuion && !(paneType === 'input' && hasInputOverwrite)
|
||||
"
|
||||
:class="$style.relatedExecutionInfo"
|
||||
data-test-id="related-execution-link"
|
||||
:href="resolveRelatedExecutionUrl(activeTaskMetadata)"
|
||||
target="_blank"
|
||||
@click.stop="trackOpeningRelatedExecution(activeTaskMetadata, displayMode)"
|
||||
>
|
||||
<N8nIcon icon="external-link-alt" size="xsmall" />
|
||||
{{ getExecutionLinkLabel(activeTaskMetadata) }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div ref="dataContainerRef" :class="$style.dataContainer" data-test-id="ndv-data-container">
|
||||
|
@ -1877,6 +1975,7 @@ defineExpose({ enterEditMode });
|
|||
padding-left: var(--spacing-s);
|
||||
padding-right: var(--spacing-s);
|
||||
padding-bottom: var(--spacing-s);
|
||||
flex-flow: wrap;
|
||||
|
||||
.itemsText {
|
||||
flex-shrink: 0;
|
||||
|
@ -1898,24 +1997,31 @@ defineExpose({ enterEditMode });
|
|||
}
|
||||
|
||||
.runSelector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: wrap;
|
||||
padding-left: var(--spacing-s);
|
||||
padding-right: var(--spacing-s);
|
||||
padding-bottom: var(--spacing-s);
|
||||
display: flex;
|
||||
gap: var(--spacing-4xs);
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-s);
|
||||
gap: var(--spacing-3xs);
|
||||
|
||||
:global(.el-input--suffix .el-input__inner) {
|
||||
padding-right: var(--spacing-l);
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
margin-left: auto;
|
||||
.runSelectorInner {
|
||||
display: flex;
|
||||
gap: var(--spacing-4xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.runSelectorInner {
|
||||
max-width: 172px;
|
||||
.runSelectorSelect {
|
||||
max-width: 205px;
|
||||
}
|
||||
|
||||
.search {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
|
@ -2075,6 +2181,15 @@ defineExpose({ enterEditMode });
|
|||
.schema {
|
||||
padding: 0 var(--spacing-s);
|
||||
}
|
||||
|
||||
.relatedExecutionInfo {
|
||||
font-size: var(--font-size-s);
|
||||
margin-left: var(--spacing-3xs);
|
||||
|
||||
svg {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -60,6 +60,7 @@ function getReferencedData(
|
|||
metadata: {
|
||||
executionTime: taskData.executionTime,
|
||||
startTime: taskData.startTime,
|
||||
subExecution: taskData.metadata?.subExecution,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,6 +11,7 @@ import type {
|
|||
import { computed } from 'vue';
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
import AiRunContentBlock from './AiRunContentBlock.vue';
|
||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
|
||||
interface RunMeta {
|
||||
startTimeMs: number;
|
||||
|
@ -18,6 +19,10 @@ interface RunMeta {
|
|||
node: INodeTypeDescription | null;
|
||||
type: 'input' | 'output';
|
||||
connectionType: NodeConnectionType;
|
||||
subExecution?: {
|
||||
workflowId: string;
|
||||
executionId: string;
|
||||
};
|
||||
}
|
||||
const props = defineProps<{
|
||||
inputData: IAiData;
|
||||
|
@ -27,6 +32,8 @@ const props = defineProps<{
|
|||
const nodeTypesStore = useNodeTypesStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = useExecutionHelpers();
|
||||
|
||||
type TokenUsageData = {
|
||||
completionTokens: number;
|
||||
promptTokens: number;
|
||||
|
@ -75,6 +82,7 @@ function extractRunMeta(run: IAiDataContent) {
|
|||
node: nodeType,
|
||||
type: run.inOut,
|
||||
connectionType: run.type,
|
||||
subExecution: run.metadata?.subExecution,
|
||||
};
|
||||
|
||||
return runMeta;
|
||||
|
@ -131,6 +139,22 @@ const outputError = computed(() => {
|
|||
}}
|
||||
</n8n-tooltip>
|
||||
</li>
|
||||
<li v-if="runMeta?.subExecution">
|
||||
<a
|
||||
:href="resolveRelatedExecutionUrl(runMeta)"
|
||||
target="_blank"
|
||||
@click.stop="trackOpeningRelatedExecution(runMeta, 'ai')"
|
||||
>
|
||||
<N8nIcon icon="external-link-alt" size="xsmall" />
|
||||
{{
|
||||
$locale.baseText('runData.openSubExecution', {
|
||||
interpolate: {
|
||||
id: runMeta.subExecution?.executionId,
|
||||
},
|
||||
})
|
||||
}}
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="(consumedTokensSum?.totalTokens ?? 0) > 0" :class="$style.tokensUsage">
|
||||
{{
|
||||
$locale.baseText('runData.aiContentBlock.tokens', {
|
||||
|
|
|
@ -13,8 +13,9 @@ import MappingPill from './MappingPill.vue';
|
|||
import TextWithHighlights from './TextWithHighlights.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { N8nInfoTip, N8nTooltip, N8nTree } from 'n8n-design-system';
|
||||
import { N8nIconButton, N8nInfoTip, N8nTooltip, N8nTree } from 'n8n-design-system';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
|
||||
const MAX_COLUMNS_LIMIT = 40;
|
||||
|
||||
|
@ -63,6 +64,7 @@ const workflowsStore = useWorkflowsStore();
|
|||
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = useExecutionHelpers();
|
||||
|
||||
const {
|
||||
hoveringItem,
|
||||
|
@ -116,6 +118,18 @@ function isHoveringRow(row: number): boolean {
|
|||
return pairedItemMappings.value[itemNodeId].has(hoveringItemId);
|
||||
}
|
||||
|
||||
function showExecutionLink(index: number) {
|
||||
if (index === activeRow.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (activeRow.value === null) {
|
||||
return index === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function onMouseEnterCell(e: MouseEvent) {
|
||||
const target = e.target;
|
||||
if (target && props.mappingEnabled) {
|
||||
|
@ -304,6 +318,11 @@ function convertToTable(inputData: INodeExecutionData[]): ITableData {
|
|||
let leftEntryColumns: string[], entryRows: GenericValue[];
|
||||
// Go over all entries
|
||||
let entry: IDataObject;
|
||||
|
||||
const metadata: ITableData['metadata'] = {
|
||||
hasExecutionIds: false,
|
||||
data: [],
|
||||
};
|
||||
const hasJson: { [key: string]: boolean } = {};
|
||||
inputData.forEach((data) => {
|
||||
if (!data.hasOwnProperty('json')) {
|
||||
|
@ -322,6 +341,13 @@ function convertToTable(inputData: INodeExecutionData[]): ITableData {
|
|||
leftEntryColumns = entryColumns;
|
||||
}
|
||||
|
||||
if (data.metadata?.subExecution) {
|
||||
metadata.data.push(data.metadata);
|
||||
metadata.hasExecutionIds = true;
|
||||
} else {
|
||||
metadata.data.push(undefined);
|
||||
}
|
||||
|
||||
// Go over all the already existing column-keys
|
||||
tableColumns.forEach((key) => {
|
||||
if (entry.hasOwnProperty(key)) {
|
||||
|
@ -368,6 +394,7 @@ function convertToTable(inputData: INodeExecutionData[]): ITableData {
|
|||
hasJson,
|
||||
columns: tableColumns,
|
||||
data: resultTableData,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -390,6 +417,9 @@ watch(focusedMappableInput, (curr) => {
|
|||
<table v-if="tableData.columns && tableData.columns.length === 0" :class="$style.table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="tableData.metadata.hasExecutionIds" :class="$style.executionLinkRowHeader">
|
||||
<!-- column for execution link -->
|
||||
</th>
|
||||
<th :class="$style.emptyCell"></th>
|
||||
<th :class="$style.tableRightMargin"></th>
|
||||
</tr>
|
||||
|
@ -400,6 +430,37 @@ watch(focusedMappableInput, (curr) => {
|
|||
:key="index1"
|
||||
:class="{ [$style.hoveringRow]: isHoveringRow(index1) }"
|
||||
>
|
||||
<td
|
||||
v-if="tableData.metadata.hasExecutionIds"
|
||||
:data-row="index1"
|
||||
:class="$style.executionLinkCell"
|
||||
@mouseenter="onMouseEnterCell"
|
||||
@mouseleave="onMouseLeaveCell"
|
||||
>
|
||||
<N8nTooltip
|
||||
:content="
|
||||
i18n.baseText('runData.table.inspectSubExecution', {
|
||||
interpolate: {
|
||||
id: `${tableData.metadata.data[index1]?.subExecution.executionId}`,
|
||||
},
|
||||
})
|
||||
"
|
||||
placement="left"
|
||||
:hide-after="0"
|
||||
>
|
||||
<N8nIconButton
|
||||
v-if="tableData.metadata.data[index1]"
|
||||
v-show="showExecutionLink(index1)"
|
||||
type="secondary"
|
||||
icon="external-link-alt"
|
||||
data-test-id="debug-sub-execution"
|
||||
size="mini"
|
||||
:href="resolveRelatedExecutionUrl(tableData.metadata.data[index1])"
|
||||
target="_blank"
|
||||
@click="trackOpeningRelatedExecution(tableData.metadata.data[index1], 'table')"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</td>
|
||||
<td
|
||||
:data-row="index1"
|
||||
:data-col="0"
|
||||
|
@ -415,6 +476,9 @@ watch(focusedMappableInput, (curr) => {
|
|||
<table v-else :class="$style.table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="tableData.metadata.hasExecutionIds" :class="$style.executionLinkRowHeader">
|
||||
<!-- column for execution link -->
|
||||
</th>
|
||||
<th v-for="(column, i) in tableData.columns || []" :key="column">
|
||||
<N8nTooltip placement="bottom-start" :disabled="!mappingEnabled" :show-after="1000">
|
||||
<template #content>
|
||||
|
@ -502,6 +566,40 @@ watch(focusedMappableInput, (curr) => {
|
|||
:class="{ [$style.hoveringRow]: isHoveringRow(index1) }"
|
||||
:data-test-id="isHoveringRow(index1) ? 'hovering-item' : undefined"
|
||||
>
|
||||
<td
|
||||
v-if="tableData.metadata.hasExecutionIds"
|
||||
:data-row="index1"
|
||||
:class="$style.executionLinkCell"
|
||||
@mouseenter="onMouseEnterCell"
|
||||
@mouseleave="onMouseLeaveCell"
|
||||
>
|
||||
<N8nTooltip
|
||||
:content="
|
||||
i18n.baseText('runData.table.inspectSubExecution', {
|
||||
interpolate: {
|
||||
id: `${tableData.metadata.data[index1]?.subExecution.executionId}`,
|
||||
},
|
||||
})
|
||||
"
|
||||
placement="left"
|
||||
:hide-after="0"
|
||||
>
|
||||
<a
|
||||
v-if="tableData.metadata.data[index1]"
|
||||
v-show="showExecutionLink(index1)"
|
||||
:href="resolveRelatedExecutionUrl(tableData.metadata.data[index1])"
|
||||
target="_blank"
|
||||
@click="trackOpeningRelatedExecution(tableData.metadata.data[index1], 'table')"
|
||||
>
|
||||
<N8nIconButton
|
||||
type="secondary"
|
||||
icon="external-link-alt"
|
||||
data-test-id="debug-sub-execution"
|
||||
size="mini"
|
||||
/>
|
||||
</a>
|
||||
</N8nTooltip>
|
||||
</td>
|
||||
<td
|
||||
v-for="(data, index2) in row"
|
||||
:key="index2"
|
||||
|
@ -736,4 +834,12 @@ watch(focusedMappableInput, (curr) => {
|
|||
.warningTooltip {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.executionLinkCell {
|
||||
padding: var(--spacing-3xs) !important;
|
||||
}
|
||||
|
||||
.executionLinkRowHeader {
|
||||
width: var(--spacing-m);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,8 +2,7 @@
|
|||
import { ref, computed, useCssModule } from 'vue';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { VIEWS, WAIT_TIME_UNLIMITED } from '@/constants';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { WAIT_TIME_UNLIMITED } from '@/constants';
|
||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||
import { i18n as locale } from '@/plugins/i18n';
|
||||
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
||||
|
@ -35,7 +34,6 @@ const props = withDefaults(
|
|||
|
||||
const style = useCssModule();
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const executionHelpers = useExecutionHelpers();
|
||||
|
||||
const isStopping = ref(false);
|
||||
|
@ -138,11 +136,7 @@ function formatDate(fullDate: Date | string | number) {
|
|||
}
|
||||
|
||||
function displayExecution() {
|
||||
const route = router.resolve({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: props.execution.workflowId, executionId: props.execution.id },
|
||||
});
|
||||
window.open(route.href, '_blank');
|
||||
executionHelpers.openExecutionInNewTab(props.execution.id, props.execution.workflowId);
|
||||
}
|
||||
|
||||
function onStopExecution() {
|
||||
|
|
|
@ -3,6 +3,22 @@ import type { ExecutionSummary } from 'n8n-workflow';
|
|||
import { i18n } from '@/plugins/i18n';
|
||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||
|
||||
const { resolve, track } = vi.hoisted(() => ({
|
||||
resolve: vi.fn(),
|
||||
track: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
resolve,
|
||||
}),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useTelemetry', () => ({
|
||||
useTelemetry: () => ({ track }),
|
||||
}));
|
||||
|
||||
describe('useExecutionHelpers()', () => {
|
||||
describe('getUIDetails()', () => {
|
||||
it.each([
|
||||
|
@ -68,4 +84,70 @@ describe('useExecutionHelpers()', () => {
|
|||
).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openExecutionInNewTab', () => {
|
||||
const executionId = '123';
|
||||
const workflowId = 'xyz';
|
||||
const href = 'test.com';
|
||||
global.window.open = vi.fn();
|
||||
|
||||
it('opens execution in new tab', () => {
|
||||
const { openExecutionInNewTab } = useExecutionHelpers();
|
||||
resolve.mockReturnValue({ href });
|
||||
openExecutionInNewTab(executionId, workflowId);
|
||||
expect(window.open).toHaveBeenCalledWith(href, '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackOpeningRelatedExecution', () => {
|
||||
it('tracks sub execution click', () => {
|
||||
const { trackOpeningRelatedExecution } = useExecutionHelpers();
|
||||
|
||||
trackOpeningRelatedExecution(
|
||||
{ subExecution: { executionId: '123', workflowId: 'xyz' } },
|
||||
'table',
|
||||
);
|
||||
|
||||
expect(track).toHaveBeenCalledWith('User clicked inspect sub-workflow', { view: 'table' });
|
||||
});
|
||||
|
||||
it('tracks parent execution click', () => {
|
||||
const { trackOpeningRelatedExecution } = useExecutionHelpers();
|
||||
|
||||
trackOpeningRelatedExecution(
|
||||
{ parentExecution: { executionId: '123', workflowId: 'xyz' } },
|
||||
'json',
|
||||
);
|
||||
|
||||
expect(track).toHaveBeenCalledWith('User clicked parent execution button', { view: 'json' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRelatedExecutionUrl', () => {
|
||||
it('resolves sub execution url', () => {
|
||||
const fullPath = 'test.com';
|
||||
resolve.mockReturnValue({ fullPath });
|
||||
const { resolveRelatedExecutionUrl } = useExecutionHelpers();
|
||||
|
||||
expect(
|
||||
resolveRelatedExecutionUrl({ subExecution: { executionId: '123', workflowId: 'xyz' } }),
|
||||
).toEqual(fullPath);
|
||||
});
|
||||
|
||||
it('resolves parent execution url', () => {
|
||||
const fullPath = 'test.com';
|
||||
resolve.mockReturnValue({ fullPath });
|
||||
const { resolveRelatedExecutionUrl } = useExecutionHelpers();
|
||||
|
||||
expect(
|
||||
resolveRelatedExecutionUrl({ parentExecution: { executionId: '123', workflowId: 'xyz' } }),
|
||||
).toEqual(fullPath);
|
||||
});
|
||||
|
||||
it('returns empty if no related execution url', () => {
|
||||
const { resolveRelatedExecutionUrl } = useExecutionHelpers();
|
||||
|
||||
expect(resolveRelatedExecutionUrl({})).toEqual('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import type { ExecutionSummary, RelatedExecution } from 'n8n-workflow';
|
||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useTelemetry } from './useTelemetry';
|
||||
import type { IRunDataDisplayMode } from '@/Interface';
|
||||
|
||||
export interface IExecutionUIData {
|
||||
name: string;
|
||||
|
@ -14,6 +18,8 @@ export interface IExecutionUIData {
|
|||
|
||||
export function useExecutionHelpers() {
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
function getUIDetails(execution: ExecutionSummary): IExecutionUIData {
|
||||
const status = {
|
||||
|
@ -69,9 +75,57 @@ export function useExecutionHelpers() {
|
|||
return ['crashed', 'error'].includes(execution.status) && !execution.retrySuccessId;
|
||||
}
|
||||
|
||||
function openExecutionInNewTab(executionId: string, workflowId: string): void {
|
||||
const route = router.resolve({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: workflowId, executionId },
|
||||
});
|
||||
|
||||
window.open(route.href, '_blank');
|
||||
}
|
||||
|
||||
function resolveRelatedExecutionUrl(metadata: {
|
||||
parentExecution?: RelatedExecution;
|
||||
subExecution?: RelatedExecution;
|
||||
}): string {
|
||||
const info = metadata.parentExecution || metadata.subExecution;
|
||||
if (!info) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { workflowId, executionId } = info;
|
||||
|
||||
return router.resolve({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: workflowId, executionId },
|
||||
}).fullPath;
|
||||
}
|
||||
|
||||
function trackOpeningRelatedExecution(
|
||||
metadata: { parentExecution?: RelatedExecution; subExecution?: RelatedExecution },
|
||||
view: IRunDataDisplayMode,
|
||||
) {
|
||||
const info = metadata.parentExecution || metadata.subExecution;
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
|
||||
telemetry.track(
|
||||
metadata.parentExecution
|
||||
? 'User clicked parent execution button'
|
||||
: 'User clicked inspect sub-workflow',
|
||||
{
|
||||
view,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getUIDetails,
|
||||
formatDate,
|
||||
isExecutionRetriable,
|
||||
openExecutionInNewTab,
|
||||
trackOpeningRelatedExecution,
|
||||
resolveRelatedExecutionUrl,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -566,6 +566,29 @@ export function useNodeHelpers() {
|
|||
}
|
||||
}
|
||||
|
||||
function getNodeTaskData(node: INodeUi | null, runIndex = 0) {
|
||||
if (node === null) {
|
||||
return null;
|
||||
}
|
||||
if (workflowsStore.getWorkflowExecution === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const executionData = workflowsStore.getWorkflowExecution.data;
|
||||
if (!executionData?.resultData) {
|
||||
// unknown status
|
||||
return null;
|
||||
}
|
||||
const runData = executionData.resultData.runData;
|
||||
|
||||
const taskData = get(runData, [node.name, runIndex]);
|
||||
if (!taskData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return taskData;
|
||||
}
|
||||
|
||||
function getNodeInputData(
|
||||
node: INodeUi | null,
|
||||
runIndex = 0,
|
||||
|
@ -583,22 +606,8 @@ export function useNodeHelpers() {
|
|||
runIndex = runIndex - 1;
|
||||
}
|
||||
|
||||
if (node === null) {
|
||||
return [];
|
||||
}
|
||||
if (workflowsStore.getWorkflowExecution === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const executionData = workflowsStore.getWorkflowExecution.data;
|
||||
if (!executionData?.resultData) {
|
||||
// unknown status
|
||||
return [];
|
||||
}
|
||||
const runData = executionData.resultData.runData;
|
||||
|
||||
const taskData = get(runData, [node.name, runIndex]);
|
||||
if (!taskData) {
|
||||
const taskData = getNodeTaskData(node, runIndex);
|
||||
if (taskData === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
@ -1282,5 +1291,6 @@ export function useNodeHelpers() {
|
|||
removeConnectionByConnectionInfo,
|
||||
addPinDataConnections,
|
||||
removePinDataConnections,
|
||||
getNodeTaskData,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -966,6 +966,7 @@
|
|||
"ndv.output.branch": "Branch",
|
||||
"ndv.output.executing": "Executing node...",
|
||||
"ndv.output.items": "{count} item | {count} items",
|
||||
"ndv.output.andSubExecutions": ", {count} sub-execution | , {count} sub-executions",
|
||||
"ndv.output.noOutputData.message": "n8n stops executing the workflow when a node has no output data. You can change this default behaviour via",
|
||||
"ndv.output.noOutputData.message.settings": "Settings",
|
||||
"ndv.output.noOutputData.message.settingsOption": "> \"Always Output Data\".",
|
||||
|
@ -1564,6 +1565,8 @@
|
|||
"resourceMapper.addAllFields": "Add All {fieldWord}",
|
||||
"resourceMapper.removeAllFields": "Remove All {fieldWord}",
|
||||
"resourceMapper.refreshFieldList": "Refresh {fieldWord} List",
|
||||
"runData.openSubExecution": "Inspect Sub-Execution {id}",
|
||||
"runData.openParentExecution": "Inspect Parent Execution {id}",
|
||||
"runData.emptyItemHint": "This is an item, but it's empty.",
|
||||
"runData.emptyArray": "[empty array]",
|
||||
"runData.emptyString": "[empty]",
|
||||
|
@ -1611,6 +1614,7 @@
|
|||
"runData.showBinaryData": "View",
|
||||
"runData.startTime": "Start Time",
|
||||
"runData.table": "Table",
|
||||
"runData.table.inspectSubExecution": "Inspect sub-execution {id}",
|
||||
"runData.pindata.learnMore": "Learn more",
|
||||
"runData.pindata.thisDataIsPinned": "This data is pinned.",
|
||||
"runData.pindata.unpin": "Unpin",
|
||||
|
@ -2623,6 +2627,8 @@
|
|||
"executionUsage.button.upgrade": "Upgrade plan",
|
||||
"executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.",
|
||||
"executionUsage.ranOutOfExecutions.text": "You’re out of executions. Upgrade your plan to keep automating.",
|
||||
"openExecution.missingExeuctionId.title": "Could not find execution",
|
||||
"openExecution.missingExeuctionId.message": "Make sure this workflow saves executions via the settings",
|
||||
"type.string": "String",
|
||||
"type.number": "Number",
|
||||
"type.dateTime": "Date & Time",
|
||||
|
|
|
@ -25,6 +25,7 @@ const route = useRoute();
|
|||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const { callDebounced } = useDebounce();
|
||||
|
||||
const workflowHelpers = useWorkflowHelpers({ router });
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
|
||||
|
@ -100,6 +101,16 @@ async function fetchExecution() {
|
|||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('nodeView.showError.openExecution.title'));
|
||||
}
|
||||
|
||||
if (!currentExecution.value) {
|
||||
toast.showMessage({
|
||||
type: 'error',
|
||||
title: i18n.baseText('openExecution.missingExeuctionId.title'),
|
||||
message: i18n.baseText('openExecution.missingExeuctionId.message'),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentVisibilityChange() {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||
import type {
|
||||
ExecuteWorkflowData,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
|
@ -209,8 +210,11 @@ export class ExecuteWorkflow implements INodeType {
|
|||
const mode = this.getNodeParameter('mode', 0, false) as string;
|
||||
const items = this.getInputData();
|
||||
|
||||
const workflowProxy = this.getWorkflowDataProxy(0);
|
||||
const currentWorkflowId = workflowProxy.$workflow.id as string;
|
||||
|
||||
if (mode === 'each') {
|
||||
let returnData: INodeExecutionData[][] = [];
|
||||
const returnData: INodeExecutionData[][] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
|
@ -222,14 +226,28 @@ export class ExecuteWorkflow implements INodeType {
|
|||
const workflowInfo = await getWorkflowInfo.call(this, source, i);
|
||||
|
||||
if (waitForSubWorkflow) {
|
||||
const workflowResult: INodeExecutionData[][] = await this.executeWorkflow(
|
||||
const executionResult: ExecuteWorkflowData = await this.executeWorkflow(
|
||||
workflowInfo,
|
||||
[items[i]],
|
||||
undefined,
|
||||
{
|
||||
parentExecution: {
|
||||
executionId: workflowProxy.$execution.id,
|
||||
workflowId: workflowProxy.$workflow.id,
|
||||
},
|
||||
},
|
||||
);
|
||||
const workflowResult = executionResult.data as INodeExecutionData[][];
|
||||
|
||||
for (const [outputIndex, outputData] of workflowResult.entries()) {
|
||||
for (const item of outputData) {
|
||||
item.pairedItem = { item: i };
|
||||
item.metadata = {
|
||||
subExecution: {
|
||||
executionId: executionResult.executionId,
|
||||
workflowId: workflowInfo.id ?? currentWorkflowId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (returnData[outputIndex] === undefined) {
|
||||
|
@ -239,8 +257,32 @@ export class ExecuteWorkflow implements INodeType {
|
|||
returnData[outputIndex].push(...outputData);
|
||||
}
|
||||
} else {
|
||||
void this.executeWorkflow(workflowInfo, [items[i]]);
|
||||
returnData = [items];
|
||||
const executionResult: ExecuteWorkflowData = await this.executeWorkflow(
|
||||
workflowInfo,
|
||||
[items[i]],
|
||||
undefined,
|
||||
{
|
||||
doNotWaitToFinish: true,
|
||||
parentExecution: {
|
||||
executionId: workflowProxy.$execution.id,
|
||||
workflowId: workflowProxy.$workflow.id,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (returnData.length === 0) {
|
||||
returnData.push([]);
|
||||
}
|
||||
|
||||
returnData[0].push({
|
||||
...items[i],
|
||||
metadata: {
|
||||
subExecution: {
|
||||
workflowId: workflowInfo.id ?? currentWorkflowId,
|
||||
executionId: executionResult.executionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
|
@ -258,6 +300,10 @@ export class ExecuteWorkflow implements INodeType {
|
|||
}
|
||||
}
|
||||
|
||||
this.setMetadata({
|
||||
subExecutionsCount: items.length,
|
||||
});
|
||||
|
||||
return returnData;
|
||||
} else {
|
||||
try {
|
||||
|
@ -268,15 +314,32 @@ export class ExecuteWorkflow implements INodeType {
|
|||
) as boolean;
|
||||
const workflowInfo = await getWorkflowInfo.call(this, source);
|
||||
|
||||
const executionResult: ExecuteWorkflowData = await this.executeWorkflow(
|
||||
workflowInfo,
|
||||
items,
|
||||
undefined,
|
||||
{
|
||||
doNotWaitToFinish: !waitForSubWorkflow,
|
||||
parentExecution: {
|
||||
executionId: workflowProxy.$execution.id,
|
||||
workflowId: workflowProxy.$workflow.id,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.setMetadata({
|
||||
subExecution: {
|
||||
executionId: executionResult.executionId,
|
||||
workflowId: workflowInfo.id ?? (workflowProxy.$workflow.id as string),
|
||||
},
|
||||
subExecutionsCount: 1,
|
||||
});
|
||||
|
||||
if (!waitForSubWorkflow) {
|
||||
void this.executeWorkflow(workflowInfo, items);
|
||||
return [items];
|
||||
}
|
||||
|
||||
const workflowResult: INodeExecutionData[][] = await this.executeWorkflow(
|
||||
workflowInfo,
|
||||
items,
|
||||
);
|
||||
const workflowResult = executionResult.data as INodeExecutionData[][];
|
||||
|
||||
const fallbackPairedItemData = generatePairedItemData(items.length);
|
||||
|
||||
|
|
|
@ -484,6 +484,7 @@ export interface ISourceDataConnections {
|
|||
|
||||
export interface IExecuteData {
|
||||
data: ITaskDataConnections;
|
||||
metadata?: ITaskMetadata;
|
||||
node: INode;
|
||||
source: ITaskDataConnectionsSource | null;
|
||||
}
|
||||
|
@ -936,6 +937,7 @@ export type ContextType = 'flow' | 'node';
|
|||
|
||||
type BaseExecutionFunctions = FunctionsBaseWithRequiredKeys<'getMode'> & {
|
||||
continueOnFail(): boolean;
|
||||
setMetadata(metadata: ITaskMetadata): void;
|
||||
evaluateExpression(expression: string, itemIndex: number): NodeParameterValueType;
|
||||
getContext(type: ContextType): IContextObject;
|
||||
getExecuteData(): IExecuteData;
|
||||
|
@ -953,7 +955,11 @@ export type IExecuteFunctions = ExecuteFunctions.GetNodeParameterFn &
|
|||
workflowInfo: IExecuteWorkflowInfo,
|
||||
inputData?: INodeExecutionData[],
|
||||
parentCallbackManager?: CallbackManager,
|
||||
): Promise<any>;
|
||||
options?: {
|
||||
doNotWaitToFinish?: boolean;
|
||||
parentExecution?: RelatedExecution;
|
||||
},
|
||||
): Promise<ExecuteWorkflowData>;
|
||||
getInputConnectionData(
|
||||
inputName: NodeConnectionType,
|
||||
itemIndex: number,
|
||||
|
@ -976,6 +982,7 @@ export type IExecuteFunctions = ExecuteFunctions.GetNodeParameterFn &
|
|||
connectionType: NodeConnectionType,
|
||||
currentNodeRunIndex: number,
|
||||
data: INodeExecutionData[][] | ExecutionError,
|
||||
metadata?: ITaskMetadata,
|
||||
): void;
|
||||
|
||||
nodeHelpers: NodeHelperFunctions;
|
||||
|
@ -1208,6 +1215,9 @@ export interface INodeExecutionData {
|
|||
binary?: IBinaryKeyData;
|
||||
error?: NodeApiError | NodeOperationError;
|
||||
pairedItem?: IPairedItemData | IPairedItemData[] | number;
|
||||
metadata?: {
|
||||
subExecution: RelatedExecution;
|
||||
};
|
||||
index?: number;
|
||||
}
|
||||
|
||||
|
@ -1545,6 +1555,11 @@ export interface ITriggerResponse {
|
|||
manualTriggerResponse?: Promise<INodeExecutionData[][]>;
|
||||
}
|
||||
|
||||
export interface ExecuteWorkflowData {
|
||||
executionId: string;
|
||||
data: Array<INodeExecutionData[] | null>;
|
||||
}
|
||||
|
||||
export type WebhookSetupMethodNames = 'checkExists' | 'create' | 'delete';
|
||||
|
||||
export namespace MultiPartFormData {
|
||||
|
@ -2133,8 +2148,16 @@ export interface ITaskSubRunMetadata {
|
|||
runIndex: number;
|
||||
}
|
||||
|
||||
export interface RelatedExecution {
|
||||
executionId: string;
|
||||
workflowId: string;
|
||||
}
|
||||
|
||||
export interface ITaskMetadata {
|
||||
subRun?: ITaskSubRunMetadata[];
|
||||
parentExecution?: RelatedExecution;
|
||||
subExecution?: RelatedExecution;
|
||||
subExecutionsCount?: number;
|
||||
}
|
||||
|
||||
// The data that gets returned when a node runs
|
||||
|
@ -2261,6 +2284,8 @@ export interface ExecuteWorkflowOptions {
|
|||
loadedRunData?: IWorkflowExecutionDataProcess;
|
||||
parentWorkflowSettings?: IWorkflowSettings;
|
||||
parentCallbackManager?: CallbackManager;
|
||||
doNotWaitToFinish?: boolean;
|
||||
parentExecution?: RelatedExecution;
|
||||
}
|
||||
|
||||
export type AiEvent =
|
||||
|
@ -2294,7 +2319,7 @@ export interface IWorkflowExecuteAdditionalData {
|
|||
workflowInfo: IExecuteWorkflowInfo,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
options: ExecuteWorkflowOptions,
|
||||
) => Promise<any>;
|
||||
) => Promise<ExecuteWorkflowData>;
|
||||
executionId?: string;
|
||||
restartExecutionId?: string;
|
||||
hooks?: WorkflowHooks;
|
||||
|
|
Loading…
Reference in a new issue