mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Add ‘execute workflow’ buttons below triggers on the canvas (#12769)
Co-authored-by: Danny Martini <danny@n8n.io> Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
This commit is contained in:
parent
e92556260f
commit
b17cbec3af
|
@ -4,6 +4,10 @@
|
||||||
|
|
||||||
import { getVisiblePopper, getVisibleSelect } from '../utils/popper';
|
import { getVisiblePopper, getVisibleSelect } from '../utils/popper';
|
||||||
|
|
||||||
|
export function getNdvContainer() {
|
||||||
|
return cy.getByTestId('ndv');
|
||||||
|
}
|
||||||
|
|
||||||
export function getCredentialSelect(eq = 0) {
|
export function getCredentialSelect(eq = 0) {
|
||||||
return cy.getByTestId('node-credentials-select').eq(eq);
|
return cy.getByTestId('node-credentials-select').eq(eq);
|
||||||
}
|
}
|
||||||
|
|
|
@ -101,8 +101,8 @@ export function getNodeCreatorItems() {
|
||||||
return cy.getByTestId('item-iterator-item');
|
return cy.getByTestId('item-iterator-item');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExecuteWorkflowButton() {
|
export function getExecuteWorkflowButton(triggerNodeName?: string) {
|
||||||
return cy.getByTestId('execute-workflow-button');
|
return cy.getByTestId(`execute-workflow-button${triggerNodeName ? `-${triggerNodeName}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getManualChatButton() {
|
export function getManualChatButton() {
|
||||||
|
@ -294,8 +294,8 @@ export function addRetrieverNodeToParent(nodeName: string, parentNodeName: strin
|
||||||
addSupplementalNodeToParent(nodeName, 'ai_retriever', parentNodeName);
|
addSupplementalNodeToParent(nodeName, 'ai_retriever', parentNodeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clickExecuteWorkflowButton() {
|
export function clickExecuteWorkflowButton(triggerNodeName?: string) {
|
||||||
getExecuteWorkflowButton().click();
|
getExecuteWorkflowButton(triggerNodeName).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clickManualChatButton() {
|
export function clickManualChatButton() {
|
||||||
|
|
|
@ -1,3 +1,11 @@
|
||||||
|
import { clickGetBackToCanvas, getNdvContainer, getOutputTableRow } from '../composables/ndv';
|
||||||
|
import {
|
||||||
|
clickExecuteWorkflowButton,
|
||||||
|
getExecuteWorkflowButton,
|
||||||
|
getNodeByName,
|
||||||
|
getZoomToFitButton,
|
||||||
|
openNode,
|
||||||
|
} from '../composables/workflow';
|
||||||
import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../constants';
|
import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../constants';
|
||||||
import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
|
import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
|
||||||
import { clearNotifications, errorToast, successToast } from '../pages/notifications';
|
import { clearNotifications, errorToast, successToast } from '../pages/notifications';
|
||||||
|
@ -214,6 +222,39 @@ describe('Execution', () => {
|
||||||
workflowPage.getters.clearExecutionDataButton().should('not.exist');
|
workflowPage.getters.clearExecutionDataButton().should('not.exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should test workflow with specific trigger node', () => {
|
||||||
|
cy.createFixtureWorkflow('Two_schedule_triggers.json');
|
||||||
|
|
||||||
|
getZoomToFitButton().click();
|
||||||
|
getExecuteWorkflowButton('Trigger A').should('not.be.visible');
|
||||||
|
getExecuteWorkflowButton('Trigger B').should('not.be.visible');
|
||||||
|
|
||||||
|
// Execute the workflow from trigger A
|
||||||
|
getNodeByName('Trigger A').realHover();
|
||||||
|
getExecuteWorkflowButton('Trigger A').should('be.visible');
|
||||||
|
getExecuteWorkflowButton('Trigger B').should('not.be.visible');
|
||||||
|
clickExecuteWorkflowButton('Trigger A');
|
||||||
|
|
||||||
|
// Check the output
|
||||||
|
successToast().contains('Workflow executed successfully');
|
||||||
|
openNode('Edit Fields');
|
||||||
|
getOutputTableRow(1).should('include.text', 'Trigger A');
|
||||||
|
|
||||||
|
clickGetBackToCanvas();
|
||||||
|
getNdvContainer().should('not.be.visible');
|
||||||
|
|
||||||
|
// Execute the workflow from trigger B
|
||||||
|
getNodeByName('Trigger B').realHover();
|
||||||
|
getExecuteWorkflowButton('Trigger A').should('not.be.visible');
|
||||||
|
getExecuteWorkflowButton('Trigger B').should('be.visible');
|
||||||
|
clickExecuteWorkflowButton('Trigger B');
|
||||||
|
|
||||||
|
// Check the output
|
||||||
|
successToast().contains('Workflow executed successfully');
|
||||||
|
openNode('Edit Fields');
|
||||||
|
getOutputTableRow(1).should('include.text', 'Trigger B');
|
||||||
|
});
|
||||||
|
|
||||||
describe('execution preview', () => {
|
describe('execution preview', () => {
|
||||||
it('when deleting the last execution, it should show empty state', () => {
|
it('when deleting the last execution, it should show empty state', () => {
|
||||||
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
|
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
|
||||||
|
|
76
cypress/fixtures/Two_schedule_triggers.json
Normal file
76
cypress/fixtures/Two_schedule_triggers.json
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"assignments": {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": "6a8c3d85-26f8-4f28-ace9-55a196a23d37",
|
||||||
|
"name": "prevNode",
|
||||||
|
"value": "={{ $prevNode.name }}",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [200, -100],
|
||||||
|
"id": "351ce967-0399-4a78-848a-9cc69b831796",
|
||||||
|
"name": "Edit Fields"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [{}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [0, -100],
|
||||||
|
"id": "cf2f58a8-1fbb-4c70-b2b1-9e06bee7ec47",
|
||||||
|
"name": "Trigger A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [{}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [0, 100],
|
||||||
|
"id": "4fade34e-2bfc-4a2e-a8ed-03ab2ed9c690",
|
||||||
|
"name": "Trigger B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Trigger A": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Edit Fields",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Trigger B": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Edit Fields",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"meta": {
|
||||||
|
"instanceId": "0dd4627b77a5a795ab9bf073e5812be94dd8d1a5f012248ef2a4acac09be12cb"
|
||||||
|
}
|
||||||
|
}
|
|
@ -46,5 +46,26 @@ describe('ManualExecutionService', () => {
|
||||||
name: 'node2',
|
name: 'node2',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Should return triggerToStartFrom trigger node', () => {
|
||||||
|
const data = {
|
||||||
|
pinData: {
|
||||||
|
node1: {},
|
||||||
|
node2: {},
|
||||||
|
},
|
||||||
|
triggerToStartFrom: { name: 'node3' },
|
||||||
|
} as unknown as IWorkflowExecutionDataProcess;
|
||||||
|
const workflow = {
|
||||||
|
getNode(nodeName: string) {
|
||||||
|
return {
|
||||||
|
name: nodeName,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as unknown as Workflow;
|
||||||
|
const executionStartNode = manualExecutionService.getExecutionStartNode(data, workflow);
|
||||||
|
expect(executionStartNode).toEqual({
|
||||||
|
name: 'node3',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -23,6 +23,13 @@ export class ManualExecutionService {
|
||||||
|
|
||||||
getExecutionStartNode(data: IWorkflowExecutionDataProcess, workflow: Workflow) {
|
getExecutionStartNode(data: IWorkflowExecutionDataProcess, workflow: Workflow) {
|
||||||
let startNode;
|
let startNode;
|
||||||
|
|
||||||
|
// If the user chose a trigger to start from we honor this.
|
||||||
|
if (data.triggerToStartFrom?.name) {
|
||||||
|
startNode = workflow.getNode(data.triggerToStartFrom.name) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old logic for partial executions v1
|
||||||
if (
|
if (
|
||||||
data.startNodes?.length === 1 &&
|
data.startNodes?.length === 1 &&
|
||||||
Object.keys(data.pinData ?? {}).includes(data.startNodes[0].name)
|
Object.keys(data.pinData ?? {}).includes(data.startNodes[0].name)
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import type { INode } from 'n8n-workflow';
|
import type { INode, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
import type { IWorkflowDb } from '@/interfaces';
|
import type { IWorkflowDb } from '@/interfaces';
|
||||||
|
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||||
import type { WorkflowRunner } from '@/workflow-runner';
|
import type { WorkflowRunner } from '@/workflow-runner';
|
||||||
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
|
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
|
||||||
|
|
||||||
|
import type { WorkflowRequest } from '../workflow.request';
|
||||||
|
|
||||||
const webhookNode: INode = {
|
const webhookNode: INode = {
|
||||||
name: 'Webhook',
|
name: 'Webhook',
|
||||||
type: 'n8n-nodes-base.webhook',
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
@ -63,6 +67,9 @@ describe('WorkflowExecutionService', () => {
|
||||||
mock(),
|
mock(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>({});
|
||||||
|
jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(additionalData);
|
||||||
|
|
||||||
describe('runWorkflow()', () => {
|
describe('runWorkflow()', () => {
|
||||||
test('should call `WorkflowRunner.run()`', async () => {
|
test('should call `WorkflowRunner.run()`', async () => {
|
||||||
const node = mock<INode>();
|
const node = mock<INode>();
|
||||||
|
@ -76,6 +83,222 @@ describe('WorkflowExecutionService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('executeManually()', () => {
|
||||||
|
test('should call `WorkflowRunner.run()` with correct parameters with default partial execution logic', async () => {
|
||||||
|
const executionId = 'fake-execution-id';
|
||||||
|
const userId = 'user-id';
|
||||||
|
const user = mock<User>({ id: userId });
|
||||||
|
const runPayload = mock<WorkflowRequest.ManualRunPayload>({ startNodes: [] });
|
||||||
|
|
||||||
|
workflowRunner.run.mockResolvedValue(executionId);
|
||||||
|
|
||||||
|
const result = await workflowExecutionService.executeManually(runPayload, user);
|
||||||
|
|
||||||
|
expect(workflowRunner.run).toHaveBeenCalledWith({
|
||||||
|
destinationNode: runPayload.destinationNode,
|
||||||
|
executionMode: 'manual',
|
||||||
|
runData: runPayload.runData,
|
||||||
|
pinData: undefined,
|
||||||
|
pushRef: undefined,
|
||||||
|
workflowData: runPayload.workflowData,
|
||||||
|
userId,
|
||||||
|
partialExecutionVersion: 1,
|
||||||
|
startNodes: runPayload.startNodes,
|
||||||
|
dirtyNodeNames: runPayload.dirtyNodeNames,
|
||||||
|
triggerToStartFrom: runPayload.triggerToStartFrom,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ executionId });
|
||||||
|
});
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'trigger',
|
||||||
|
type: 'n8n-nodes-base.airtableTrigger',
|
||||||
|
// Avoid mock constructor evaluated as true
|
||||||
|
disabled: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
disabled: undefined,
|
||||||
|
},
|
||||||
|
].forEach((triggerNode: Partial<INode>) => {
|
||||||
|
test(`should call WorkflowRunner.run() with pinned trigger with type ${triggerNode.name}`, async () => {
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>({});
|
||||||
|
jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(additionalData);
|
||||||
|
const executionId = 'fake-execution-id';
|
||||||
|
const userId = 'user-id';
|
||||||
|
const user = mock<User>({ id: userId });
|
||||||
|
const runPayload = mock<WorkflowRequest.ManualRunPayload>({
|
||||||
|
startNodes: [],
|
||||||
|
workflowData: {
|
||||||
|
pinData: {
|
||||||
|
trigger: [{}],
|
||||||
|
},
|
||||||
|
nodes: [triggerNode],
|
||||||
|
},
|
||||||
|
triggerToStartFrom: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRunner.run.mockResolvedValue(executionId);
|
||||||
|
|
||||||
|
const result = await workflowExecutionService.executeManually(runPayload, user);
|
||||||
|
|
||||||
|
expect(workflowRunner.run).toHaveBeenCalledWith({
|
||||||
|
destinationNode: runPayload.destinationNode,
|
||||||
|
executionMode: 'manual',
|
||||||
|
runData: runPayload.runData,
|
||||||
|
pinData: runPayload.workflowData.pinData,
|
||||||
|
pushRef: undefined,
|
||||||
|
workflowData: runPayload.workflowData,
|
||||||
|
userId,
|
||||||
|
partialExecutionVersion: 1,
|
||||||
|
startNodes: [
|
||||||
|
{
|
||||||
|
name: triggerNode.name,
|
||||||
|
sourceData: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dirtyNodeNames: runPayload.dirtyNodeNames,
|
||||||
|
triggerToStartFrom: runPayload.triggerToStartFrom,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ executionId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should start from pinned trigger', async () => {
|
||||||
|
const executionId = 'fake-execution-id';
|
||||||
|
const userId = 'user-id';
|
||||||
|
const user = mock<User>({ id: userId });
|
||||||
|
|
||||||
|
const pinnedTrigger: INode = {
|
||||||
|
id: '1',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [1, 2],
|
||||||
|
parameters: {},
|
||||||
|
name: 'pinned',
|
||||||
|
type: 'n8n-nodes-base.airtableTrigger',
|
||||||
|
};
|
||||||
|
|
||||||
|
const unexecutedTrigger: INode = {
|
||||||
|
id: '1',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [1, 2],
|
||||||
|
parameters: {},
|
||||||
|
name: 'to-start-from',
|
||||||
|
type: 'n8n-nodes-base.airtableTrigger',
|
||||||
|
};
|
||||||
|
|
||||||
|
const runPayload: WorkflowRequest.ManualRunPayload = {
|
||||||
|
startNodes: [],
|
||||||
|
workflowData: {
|
||||||
|
id: 'abc',
|
||||||
|
name: 'test',
|
||||||
|
active: false,
|
||||||
|
pinData: {
|
||||||
|
[pinnedTrigger.name]: [{ json: {} }],
|
||||||
|
},
|
||||||
|
nodes: [unexecutedTrigger, pinnedTrigger],
|
||||||
|
connections: {},
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
runData: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
workflowRunner.run.mockResolvedValue(executionId);
|
||||||
|
|
||||||
|
const result = await workflowExecutionService.executeManually(runPayload, user);
|
||||||
|
|
||||||
|
expect(workflowRunner.run).toHaveBeenCalledWith({
|
||||||
|
destinationNode: runPayload.destinationNode,
|
||||||
|
executionMode: 'manual',
|
||||||
|
runData: runPayload.runData,
|
||||||
|
pinData: runPayload.workflowData.pinData,
|
||||||
|
pushRef: undefined,
|
||||||
|
workflowData: runPayload.workflowData,
|
||||||
|
userId,
|
||||||
|
partialExecutionVersion: 1,
|
||||||
|
startNodes: [
|
||||||
|
{
|
||||||
|
// Start from pinned trigger
|
||||||
|
name: pinnedTrigger.name,
|
||||||
|
sourceData: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dirtyNodeNames: runPayload.dirtyNodeNames,
|
||||||
|
// no trigger to start from
|
||||||
|
triggerToStartFrom: undefined,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ executionId });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should ignore pinned trigger and start from unexecuted trigger', async () => {
|
||||||
|
const executionId = 'fake-execution-id';
|
||||||
|
const userId = 'user-id';
|
||||||
|
const user = mock<User>({ id: userId });
|
||||||
|
|
||||||
|
const pinnedTrigger: INode = {
|
||||||
|
id: '1',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [1, 2],
|
||||||
|
parameters: {},
|
||||||
|
name: 'pinned',
|
||||||
|
type: 'n8n-nodes-base.airtableTrigger',
|
||||||
|
};
|
||||||
|
|
||||||
|
const unexecutedTrigger: INode = {
|
||||||
|
id: '1',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [1, 2],
|
||||||
|
parameters: {},
|
||||||
|
name: 'to-start-from',
|
||||||
|
type: 'n8n-nodes-base.airtableTrigger',
|
||||||
|
};
|
||||||
|
|
||||||
|
const runPayload: WorkflowRequest.ManualRunPayload = {
|
||||||
|
startNodes: [],
|
||||||
|
workflowData: {
|
||||||
|
id: 'abc',
|
||||||
|
name: 'test',
|
||||||
|
active: false,
|
||||||
|
pinData: {
|
||||||
|
[pinnedTrigger.name]: [{ json: {} }],
|
||||||
|
},
|
||||||
|
nodes: [unexecutedTrigger, pinnedTrigger],
|
||||||
|
connections: {},
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
runData: {},
|
||||||
|
triggerToStartFrom: {
|
||||||
|
name: unexecutedTrigger.name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
workflowRunner.run.mockResolvedValue(executionId);
|
||||||
|
|
||||||
|
const result = await workflowExecutionService.executeManually(runPayload, user);
|
||||||
|
|
||||||
|
expect(workflowRunner.run).toHaveBeenCalledWith({
|
||||||
|
destinationNode: runPayload.destinationNode,
|
||||||
|
executionMode: 'manual',
|
||||||
|
runData: runPayload.runData,
|
||||||
|
pinData: runPayload.workflowData.pinData,
|
||||||
|
pushRef: undefined,
|
||||||
|
workflowData: runPayload.workflowData,
|
||||||
|
userId,
|
||||||
|
partialExecutionVersion: 1,
|
||||||
|
// ignore pinned trigger
|
||||||
|
startNodes: [],
|
||||||
|
dirtyNodeNames: runPayload.dirtyNodeNames,
|
||||||
|
// pass unexecuted trigger to start from
|
||||||
|
triggerToStartFrom: runPayload.triggerToStartFrom,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ executionId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('selectPinnedActivatorStarter()', () => {
|
describe('selectPinnedActivatorStarter()', () => {
|
||||||
const workflow = mock<IWorkflowDb>({
|
const workflow = mock<IWorkflowDb>({
|
||||||
nodes: [],
|
nodes: [],
|
||||||
|
|
|
@ -100,12 +100,18 @@ export class WorkflowExecutionService {
|
||||||
partialExecutionVersion: 1 | 2 = 1,
|
partialExecutionVersion: 1 | 2 = 1,
|
||||||
) {
|
) {
|
||||||
const pinData = workflowData.pinData;
|
const pinData = workflowData.pinData;
|
||||||
const pinnedTrigger = this.selectPinnedActivatorStarter(
|
let pinnedTrigger = this.selectPinnedActivatorStarter(
|
||||||
workflowData,
|
workflowData,
|
||||||
startNodes?.map((nodeData) => nodeData.name),
|
startNodes?.map((nodeData) => nodeData.name),
|
||||||
pinData,
|
pinData,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// if we have a trigger to start from and it's not the pinned trigger
|
||||||
|
// ignore the pinned trigger
|
||||||
|
if (pinnedTrigger && triggerToStartFrom && pinnedTrigger.name !== triggerToStartFrom.name) {
|
||||||
|
pinnedTrigger = null;
|
||||||
|
}
|
||||||
|
|
||||||
// If webhooks nodes exist and are active we have to wait for till we receive a call
|
// If webhooks nodes exist and are active we have to wait for till we receive a call
|
||||||
if (
|
if (
|
||||||
pinnedTrigger === null &&
|
pinnedTrigger === null &&
|
||||||
|
|
|
@ -405,6 +405,7 @@ export interface IExecutionResponse extends IExecutionBase {
|
||||||
data?: IRunExecutionData;
|
data?: IRunExecutionData;
|
||||||
workflowData: IWorkflowDb;
|
workflowData: IWorkflowDb;
|
||||||
executedNode?: string;
|
executedNode?: string;
|
||||||
|
triggerNode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExecutionSummaryWithScopes = ExecutionSummary & { scopes: Scope[] };
|
export type ExecutionSummaryWithScopes = ExecutionSummary & { scopes: Scope[] };
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type {
|
import {
|
||||||
CanvasConnection,
|
type CanvasConnection,
|
||||||
CanvasNode,
|
type CanvasNode,
|
||||||
CanvasNodeMoveEvent,
|
type CanvasNodeMoveEvent,
|
||||||
CanvasEventBusEvents,
|
type CanvasEventBusEvents,
|
||||||
ConnectStartEvent,
|
type ConnectStartEvent,
|
||||||
|
CanvasNodeRenderType,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import type {
|
import type {
|
||||||
Connection,
|
Connection,
|
||||||
|
@ -34,6 +35,7 @@ import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
|
||||||
import CanvasBackground from './elements/background/CanvasBackground.vue';
|
import CanvasBackground from './elements/background/CanvasBackground.vue';
|
||||||
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
|
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
|
|
||||||
|
@ -257,6 +259,20 @@ const hasSelection = computed(() => selectedNodes.value.length > 0);
|
||||||
const selectedNodeIds = computed(() => selectedNodes.value.map((node) => node.id));
|
const selectedNodeIds = computed(() => selectedNodes.value.map((node) => node.id));
|
||||||
|
|
||||||
const lastSelectedNode = ref<GraphNode>();
|
const lastSelectedNode = ref<GraphNode>();
|
||||||
|
const triggerNodes = computed(() =>
|
||||||
|
props.nodes.filter(
|
||||||
|
(node) =>
|
||||||
|
node.data?.render.type === CanvasNodeRenderType.Default && node.data.render.options.trigger,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hoveredTriggerNode = useCanvasNodeHover(triggerNodes, vueFlow, (nodeRect) => ({
|
||||||
|
x: nodeRect.x - nodeRect.width * 2, // should cover the width of trigger button
|
||||||
|
y: nodeRect.y - nodeRect.height,
|
||||||
|
width: nodeRect.width * 4,
|
||||||
|
height: nodeRect.height * 3,
|
||||||
|
}));
|
||||||
|
|
||||||
watch(selectedNodes, (nodes) => {
|
watch(selectedNodes, (nodes) => {
|
||||||
if (!lastSelectedNode.value || !nodes.find((node) => node.id === lastSelectedNode.value?.id)) {
|
if (!lastSelectedNode.value || !nodes.find((node) => node.id === lastSelectedNode.value?.id)) {
|
||||||
lastSelectedNode.value = nodes[nodes.length - 1];
|
lastSelectedNode.value = nodes[nodes.length - 1];
|
||||||
|
@ -710,6 +726,7 @@ provide(CanvasKey, {
|
||||||
:read-only="readOnly"
|
:read-only="readOnly"
|
||||||
:event-bus="eventBus"
|
:event-bus="eventBus"
|
||||||
:hovered="nodesHoveredById[nodeProps.id]"
|
:hovered="nodesHoveredById[nodeProps.id]"
|
||||||
|
:nearby-hovered="nodeProps.id === hoveredTriggerNode.id.value"
|
||||||
@delete="onDeleteNode"
|
@delete="onDeleteNode"
|
||||||
@run="onRunNode"
|
@run="onRunNode"
|
||||||
@select="onSelectNode"
|
@select="onSelectNode"
|
||||||
|
|
|
@ -16,7 +16,7 @@ import type {
|
||||||
CanvasNodeEventBusEvents,
|
CanvasNodeEventBusEvents,
|
||||||
CanvasEventBusEvents,
|
CanvasEventBusEvents,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { CanvasNodeRenderType, CanvasConnectionMode } from '@/types';
|
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
|
||||||
import NodeIcon from '@/components/NodeIcon.vue';
|
import NodeIcon from '@/components/NodeIcon.vue';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
|
import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
|
||||||
|
@ -35,11 +35,13 @@ import {
|
||||||
import type { EventBus } from 'n8n-design-system';
|
import type { EventBus } from 'n8n-design-system';
|
||||||
import { createEventBus } from 'n8n-design-system';
|
import { createEventBus } from 'n8n-design-system';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
|
import CanvasNodeTrigger from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeTrigger.vue';
|
||||||
|
|
||||||
type Props = NodeProps<CanvasNodeData> & {
|
type Props = NodeProps<CanvasNodeData> & {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
eventBus?: EventBus<CanvasEventBusEvents>;
|
eventBus?: EventBus<CanvasEventBusEvents>;
|
||||||
hovered?: boolean;
|
hovered?: boolean;
|
||||||
|
nearbyHovered?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const slots = defineSlots<{
|
const slots = defineSlots<{
|
||||||
|
@ -406,12 +408,23 @@ onBeforeUnmount(() => {
|
||||||
/>
|
/>
|
||||||
<!-- @TODO :color-default="iconColorDefault"-->
|
<!-- @TODO :color-default="iconColorDefault"-->
|
||||||
</CanvasNodeRenderer>
|
</CanvasNodeRenderer>
|
||||||
|
|
||||||
|
<CanvasNodeTrigger
|
||||||
|
v-if="
|
||||||
|
props.data.render.type === CanvasNodeRenderType.Default && props.data.render.options.trigger
|
||||||
|
"
|
||||||
|
:name="data.name"
|
||||||
|
:type="data.type"
|
||||||
|
:hovered="nearbyHovered"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
:class="$style.trigger"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.canvasNode {
|
.canvasNode {
|
||||||
&:hover,
|
&:hover:not(:has(> .trigger:hover)), // exclude .trigger which has extended hit zone
|
||||||
&:focus-within,
|
&:focus-within,
|
||||||
&.showToolbar {
|
&.showToolbar {
|
||||||
.canvasNodeToolbar {
|
.canvasNodeToolbar {
|
||||||
|
|
|
@ -3,9 +3,14 @@ import { computed, ref, useCssModule, watch } from 'vue';
|
||||||
import { useNodeConnections } from '@/composables/useNodeConnections';
|
import { useNodeConnections } from '@/composables/useNodeConnections';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useCanvasNode } from '@/composables/useCanvasNode';
|
import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||||
import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
|
import {
|
||||||
|
LOCAL_STORAGE_CANVAS_TRIGGER_BUTTON_VARIANT,
|
||||||
|
NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS,
|
||||||
|
} from '@/constants';
|
||||||
import type { CanvasNodeDefaultRender } from '@/types';
|
import type { CanvasNodeDefaultRender } from '@/types';
|
||||||
import { useCanvas } from '@/composables/useCanvas';
|
import { useCanvas } from '@/composables/useCanvas';
|
||||||
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
@ -106,6 +111,8 @@ const isStrikethroughVisible = computed(() => {
|
||||||
|
|
||||||
const showTooltip = ref(false);
|
const showTooltip = ref(false);
|
||||||
|
|
||||||
|
const triggerButtonVariant = useLocalStorage<1 | 2>(LOCAL_STORAGE_CANVAS_TRIGGER_BUTTON_VARIANT, 2);
|
||||||
|
|
||||||
watch(initialized, () => {
|
watch(initialized, () => {
|
||||||
if (initialized.value) {
|
if (initialized.value) {
|
||||||
showTooltip.value = true;
|
showTooltip.value = true;
|
||||||
|
@ -128,12 +135,14 @@ function openContextMenu(event: MouseEvent) {
|
||||||
<div :class="classes" :style="styles" :data-test-id="dataTestId" @contextmenu="openContextMenu">
|
<div :class="classes" :style="styles" :data-test-id="dataTestId" @contextmenu="openContextMenu">
|
||||||
<CanvasNodeTooltip v-if="renderOptions.tooltip" :visible="showTooltip" />
|
<CanvasNodeTooltip v-if="renderOptions.tooltip" :visible="showTooltip" />
|
||||||
<slot />
|
<slot />
|
||||||
<CanvasNodeTriggerIcon v-if="renderOptions.trigger" />
|
|
||||||
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
|
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
|
||||||
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
|
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
|
||||||
<div :class="$style.description">
|
<div :class="$style.description">
|
||||||
<div v-if="label" :class="$style.label">
|
<div v-if="label" :class="$style.label">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
|
<div v-if="renderOptions.trigger && triggerButtonVariant === 1" :class="$style.triggerIcon">
|
||||||
|
<FontAwesomeIcon icon="bolt" size="lg" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isDisabled" :class="$style.disabledLabel">
|
<div v-if="isDisabled" :class="$style.disabledLabel">
|
||||||
({{ i18n.baseText('node.disabled') }})
|
({{ i18n.baseText('node.disabled') }})
|
||||||
|
@ -313,4 +322,11 @@ function openContextMenu(event: MouseEvent) {
|
||||||
bottom: var(--canvas-node--status-icons-offset);
|
bottom: var(--canvas-node--status-icons-offset);
|
||||||
right: var(--canvas-node--status-icons-offset);
|
right: var(--canvas-node--status-icons-offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.triggerIcon {
|
||||||
|
display: inline;
|
||||||
|
position: static;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: var(--spacing-4xs);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -9,7 +9,6 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
|
|
||||||
|
|
||||||
<!--v-if-->
|
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<div
|
<div
|
||||||
|
@ -18,7 +17,8 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
|
||||||
<div
|
<div
|
||||||
class="label"
|
class="label"
|
||||||
>
|
>
|
||||||
Test Node
|
Test Node
|
||||||
|
<!--v-if-->
|
||||||
</div>
|
</div>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<div
|
<div
|
||||||
|
@ -39,7 +39,6 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
|
|
||||||
|
|
||||||
<!--v-if-->
|
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<div
|
<div
|
||||||
|
@ -48,7 +47,8 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
|
||||||
<div
|
<div
|
||||||
class="label"
|
class="label"
|
||||||
>
|
>
|
||||||
Test Node
|
Test Node
|
||||||
|
<!--v-if-->
|
||||||
</div>
|
</div>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<div
|
<div
|
||||||
|
@ -69,7 +69,6 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
|
|
||||||
|
|
||||||
<!--v-if-->
|
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<div
|
<div
|
||||||
|
@ -78,7 +77,8 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
|
||||||
<div
|
<div
|
||||||
class="label"
|
class="label"
|
||||||
>
|
>
|
||||||
Test Node
|
Test Node
|
||||||
|
<!--v-if-->
|
||||||
</div>
|
</div>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<div
|
<div
|
||||||
|
@ -99,7 +99,6 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
|
|
||||||
|
|
||||||
<!--v-if-->
|
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<div
|
<div
|
||||||
|
@ -108,7 +107,8 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
|
||||||
<div
|
<div
|
||||||
class="label"
|
class="label"
|
||||||
>
|
>
|
||||||
Test Node
|
Test Node
|
||||||
|
<!--v-if-->
|
||||||
</div>
|
</div>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<div
|
<div
|
||||||
|
@ -129,30 +129,6 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="triggerIcon el-tooltip__trigger el-tooltip__trigger"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="svg-inline--fa fa-bolt fa-w-10 fa-lg"
|
|
||||||
data-icon="bolt"
|
|
||||||
data-prefix="fas"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 320 512"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
class=""
|
|
||||||
d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<!--teleport start-->
|
|
||||||
<!--teleport end-->
|
|
||||||
|
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<div
|
<div
|
||||||
|
@ -161,7 +137,8 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
|
||||||
<div
|
<div
|
||||||
class="label"
|
class="label"
|
||||||
>
|
>
|
||||||
Test Node
|
Test Node
|
||||||
|
<!--v-if-->
|
||||||
</div>
|
</div>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -10,6 +10,12 @@ defineProps<{
|
||||||
const { render } = useCanvasNode();
|
const { render } = useCanvasNode();
|
||||||
|
|
||||||
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
|
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
|
||||||
|
|
||||||
|
const popperOptions = {
|
||||||
|
modifiers: [
|
||||||
|
{ name: 'flip', enabled: false }, // show tooltip always above the node
|
||||||
|
],
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -19,6 +25,7 @@ const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRe
|
||||||
:visible="true"
|
:visible="true"
|
||||||
:teleported="false"
|
:teleported="false"
|
||||||
:popper-class="$style.popper"
|
:popper-class="$style.popper"
|
||||||
|
:popper-options="popperOptions"
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
{{ renderOptions.tooltip }}
|
{{ renderOptions.tooltip }}
|
||||||
|
@ -33,7 +40,7 @@ const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRe
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 1px;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popper {
|
.popper {
|
||||||
|
|
|
@ -0,0 +1,163 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
|
import { CHAT_TRIGGER_NODE_TYPE, LOCAL_STORAGE_CANVAS_TRIGGER_BUTTON_VARIANT } from '@/constants';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
import { computed, useCssModule } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
hovered,
|
||||||
|
disabled,
|
||||||
|
class: cls,
|
||||||
|
} = defineProps<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
hovered?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
class?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const variant = useLocalStorage<1 | 2>(LOCAL_STORAGE_CANVAS_TRIGGER_BUTTON_VARIANT, 2);
|
||||||
|
|
||||||
|
const style = useCssModule();
|
||||||
|
const containerClass = computed(() => ({
|
||||||
|
[cls ?? '']: true,
|
||||||
|
[style.container]: true,
|
||||||
|
[style.interactive]: !disabled,
|
||||||
|
[style.hovered]: !!hovered,
|
||||||
|
[style.variant1]: variant.value === 1,
|
||||||
|
[style.variant2]: variant.value === 2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const { runEntireWorkflow } = useRunWorkflow({ router });
|
||||||
|
const { toggleChatOpen } = useCanvasOperations({ router });
|
||||||
|
|
||||||
|
const isChatOpen = computed(() => workflowsStore.isChatPanelOpen);
|
||||||
|
const isExecuting = computed(() => uiStore.isActionActive.workflowRunning);
|
||||||
|
const testId = computed(() => `execute-workflow-button-${name}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- click and mousedown event are suppressed to avoid unwanted selection or dragging of the node -->
|
||||||
|
<div :class="containerClass" @click.stop.prevent @mousedown.stop.prevent>
|
||||||
|
<div>
|
||||||
|
<div v-if="variant === 2" :class="$style.bolt">
|
||||||
|
<FontAwesomeIcon icon="bolt" size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<N8nButton
|
||||||
|
v-if="variant === 1 && type === CHAT_TRIGGER_NODE_TYPE"
|
||||||
|
type="secondary"
|
||||||
|
size="large"
|
||||||
|
:disabled="isExecuting"
|
||||||
|
:data-test-id="testId"
|
||||||
|
@click.capture="toggleChatOpen('node')"
|
||||||
|
>{{ isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open') }}</N8nButton
|
||||||
|
>
|
||||||
|
<N8nButton
|
||||||
|
v-else-if="variant === 1"
|
||||||
|
type="secondary"
|
||||||
|
size="large"
|
||||||
|
:disabled="isExecuting"
|
||||||
|
:data-test-id="testId"
|
||||||
|
@click.capture="runEntireWorkflow('node', name)"
|
||||||
|
>{{ i18n.baseText('nodeView.runButtonText.executeWorkflow') }}</N8nButton
|
||||||
|
>
|
||||||
|
<N8nButton
|
||||||
|
v-else-if="variant === 2 && type === CHAT_TRIGGER_NODE_TYPE"
|
||||||
|
:type="isChatOpen ? 'secondary' : 'primary'"
|
||||||
|
size="large"
|
||||||
|
:disabled="isExecuting"
|
||||||
|
:data-test-id="testId"
|
||||||
|
:label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
|
||||||
|
@click.capture="toggleChatOpen('node')"
|
||||||
|
/>
|
||||||
|
<N8nButton
|
||||||
|
v-else
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:disabled="isExecuting"
|
||||||
|
:data-test-id="testId"
|
||||||
|
:label="i18n.baseText('nodeView.runButtonText.executeWorkflow')"
|
||||||
|
@click.capture="runEntireWorkflow('node', name)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
z-index: -1;
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
right: 100%;
|
||||||
|
top: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hovered button {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant1 {
|
||||||
|
& button {
|
||||||
|
margin-right: var(--spacing-xl);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.interactive button {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant2 {
|
||||||
|
& button {
|
||||||
|
margin-right: var(--spacing-s);
|
||||||
|
opacity: 0;
|
||||||
|
translate: -12px 0;
|
||||||
|
transition:
|
||||||
|
translate 0.1s ease-in,
|
||||||
|
opacity 0.1s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.interactive.hovered button {
|
||||||
|
opacity: 1;
|
||||||
|
translate: 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bolt {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: var(--spacing-s);
|
||||||
|
opacity: 1;
|
||||||
|
translate: 0 0;
|
||||||
|
transition:
|
||||||
|
translate 0.1s ease-in,
|
||||||
|
opacity 0.1s ease-in;
|
||||||
|
|
||||||
|
.container.interactive.hovered & {
|
||||||
|
translate: -12px 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,35 +0,0 @@
|
||||||
import CanvasNodeTriggerIcon from './CanvasNodeTriggerIcon.vue';
|
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
|
||||||
|
|
||||||
vi.mock('@/composables/useI18n', () => ({
|
|
||||||
useI18n: vi.fn(() => ({
|
|
||||||
baseText: vi.fn().mockReturnValue('This is a trigger node'),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(CanvasNodeTriggerIcon, {
|
|
||||||
global: {
|
|
||||||
stubs: {
|
|
||||||
FontAwesomeIcon: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CanvasNodeTriggerIcon', () => {
|
|
||||||
it('should render trigger icon with tooltip', () => {
|
|
||||||
const { container } = renderComponent();
|
|
||||||
|
|
||||||
expect(container.querySelector('.triggerIcon')).toBeInTheDocument();
|
|
||||||
|
|
||||||
const icon = container.querySelector('font-awesome-icon-stub');
|
|
||||||
expect(icon).toBeInTheDocument();
|
|
||||||
expect(icon?.getAttribute('icon')).toBe('bolt');
|
|
||||||
expect(icon?.getAttribute('size')).toBe('lg');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render tooltip with correct content', () => {
|
|
||||||
const { getByText } = renderComponent();
|
|
||||||
|
|
||||||
expect(getByText('This is a trigger node')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,26 +0,0 @@
|
||||||
<script lang="ts" setup>
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
|
||||||
|
|
||||||
const i18n = useI18n();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<N8nTooltip placement="bottom">
|
|
||||||
<template #content>
|
|
||||||
<span v-n8n-html="i18n.baseText('node.thisIsATriggerNode')" />
|
|
||||||
</template>
|
|
||||||
<div :class="$style.triggerIcon">
|
|
||||||
<FontAwesomeIcon icon="bolt" size="lg" />
|
|
||||||
</div>
|
|
||||||
</N8nTooltip>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" module>
|
|
||||||
.triggerIcon {
|
|
||||||
position: absolute;
|
|
||||||
right: 100%;
|
|
||||||
margin: auto;
|
|
||||||
color: var(--color-primary);
|
|
||||||
padding: var(--spacing-2xs);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -133,16 +133,6 @@ export function useCanvasMapping({
|
||||||
}, {}),
|
}, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeTriggerNodeCount = computed(
|
|
||||||
() =>
|
|
||||||
nodes.value.filter(
|
|
||||||
(node) =>
|
|
||||||
nodeTypeDescriptionByNodeId.value[node.id]?.eventTriggerDescription !== '' &&
|
|
||||||
isTriggerNodeById.value[node.id] &&
|
|
||||||
!node.disabled,
|
|
||||||
).length,
|
|
||||||
);
|
|
||||||
|
|
||||||
const nodeSubtitleById = computed(() => {
|
const nodeSubtitleById = computed(() => {
|
||||||
return nodes.value.reduce<Record<string, string>>((acc, node) => {
|
return nodes.value.reduce<Record<string, string>>((acc, node) => {
|
||||||
try {
|
try {
|
||||||
|
@ -255,13 +245,28 @@ export function useCanvasMapping({
|
||||||
}, {}),
|
}, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodeTooltipById = computed(() =>
|
const nodeTooltipById = computed(() => {
|
||||||
nodes.value.reduce<Record<string, string | undefined>>((acc, node) => {
|
if (!workflowsStore.isWorkflowRunning) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTriggerNodeCount = nodes.value.filter(
|
||||||
|
(node) => isTriggerNodeById.value[node.id] && !node.disabled,
|
||||||
|
).length;
|
||||||
|
const triggerNodeName = workflowsStore.getWorkflowExecution?.triggerNode;
|
||||||
|
|
||||||
|
// For workflows with multiple active trigger nodes, we show a tooltip only when
|
||||||
|
// trigger node name is known
|
||||||
|
if (triggerNodeName === undefined && activeTriggerNodeCount !== 1) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes.value.reduce<Record<string, string | undefined>>((acc, node) => {
|
||||||
const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id];
|
const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id];
|
||||||
if (nodeTypeDescription && isTriggerNodeById.value[node.id]) {
|
if (nodeTypeDescription && isTriggerNodeById.value[node.id]) {
|
||||||
if (
|
if (
|
||||||
activeTriggerNodeCount.value !== 1 ||
|
!!node.disabled ||
|
||||||
!workflowsStore.isWorkflowRunning ||
|
(triggerNodeName !== undefined && triggerNodeName !== node.name) ||
|
||||||
!['new', 'unknown', 'waiting'].includes(nodeExecutionStatusById.value[node.id])
|
!['new', 'unknown', 'waiting'].includes(nodeExecutionStatusById.value[node.id])
|
||||||
) {
|
) {
|
||||||
return acc;
|
return acc;
|
||||||
|
@ -283,8 +288,8 @@ export function useCanvasMapping({
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {}),
|
}, {});
|
||||||
);
|
});
|
||||||
|
|
||||||
const nodeExecutionRunningById = computed(() =>
|
const nodeExecutionRunningById = computed(() =>
|
||||||
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
|
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
/* eslint-disable vue/one-component-per-file */
|
||||||
|
import { renderComponent } from '@/__tests__/render';
|
||||||
|
import { type CanvasNode } from '@/types';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { type Rect, useVueFlow, VueFlow } from '@vue-flow/core';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { computed, defineComponent, h } from 'vue';
|
||||||
|
import { useCanvasNodeHover } from './useCanvasNodeHover';
|
||||||
|
|
||||||
|
describe(useCanvasNodeHover, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function getHitBoxMargin10(rect: Rect): Rect {
|
||||||
|
return {
|
||||||
|
x: rect.x - 10,
|
||||||
|
y: rect.y - 10,
|
||||||
|
width: rect.width + 20,
|
||||||
|
height: rect.height + 20,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHitBoxMargin100(rect: Rect): Rect {
|
||||||
|
return {
|
||||||
|
x: rect.x - 100,
|
||||||
|
y: rect.y - 100,
|
||||||
|
width: rect.width + 200,
|
||||||
|
height: rect.height + 200,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodesRef = computed<CanvasNode[]>(() => [
|
||||||
|
{ id: 'node-1', position: { x: 100, y: 100 } },
|
||||||
|
{ id: 'node-2', position: { x: 100, y: 200 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('should return ID of the node which a mousemove event was emitted on', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const TestComponent = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const store = useVueFlow();
|
||||||
|
return useCanvasNodeHover(nodesRef, store, getHitBoxMargin10);
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h('div', [
|
||||||
|
h('div', { 'data-test-id': 'hovered' }, this.id ?? 'no match'),
|
||||||
|
h(VueFlow, { 'data-test-id': 'canvas', nodes: nodesRef.value }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wrapper = renderComponent(TestComponent);
|
||||||
|
|
||||||
|
expect(wrapper.getByTestId('hovered')).toHaveTextContent('no match');
|
||||||
|
|
||||||
|
fireEvent.mouseMove(wrapper.getByTestId('canvas'), { clientX: 90, clientY: 90 });
|
||||||
|
await wrapper.rerender({});
|
||||||
|
expect(wrapper.getByTestId('hovered')).toHaveTextContent('node-1');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000); // Advance timer to circumvent throttling
|
||||||
|
|
||||||
|
fireEvent.mouseMove(wrapper.getByTestId('canvas'), { clientX: 110, clientY: 210 });
|
||||||
|
await wrapper.rerender({});
|
||||||
|
expect(wrapper.getByTestId('hovered')).toHaveTextContent('node-2');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000); // Advance timer to circumvent throttling
|
||||||
|
|
||||||
|
fireEvent.mouseMove(wrapper.getByTestId('canvas'), { clientX: 0, clientY: 0 });
|
||||||
|
await wrapper.rerender({});
|
||||||
|
expect(wrapper.getByTestId('hovered')).toHaveTextContent('no match');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ID of the closest node if more than one node exist near the coordinate of mousemove event', async () => {
|
||||||
|
const TestComponent = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const store = useVueFlow();
|
||||||
|
return useCanvasNodeHover(nodesRef, store, getHitBoxMargin100);
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h('div', [
|
||||||
|
h('div', { 'data-test-id': 'hovered' }, this.id ?? 'no match'),
|
||||||
|
h(VueFlow, { 'data-test-id': 'canvas', nodes: nodesRef.value }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wrapper = renderComponent(TestComponent);
|
||||||
|
|
||||||
|
fireEvent.mouseMove(wrapper.getByTestId('canvas'), { clientX: 100, clientY: 160 });
|
||||||
|
await wrapper.rerender({});
|
||||||
|
expect(wrapper.getByTestId('hovered')).toHaveTextContent('node-2');
|
||||||
|
});
|
||||||
|
});
|
82
packages/editor-ui/src/composables/useCanvasNodeHover.ts
Normal file
82
packages/editor-ui/src/composables/useCanvasNodeHover.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { type CanvasNode } from '@/types';
|
||||||
|
import { getRectOfNodes, type Rect, type VueFlowStore } from '@vue-flow/core';
|
||||||
|
import { useThrottleFn } from '@vueuse/core';
|
||||||
|
import { type ComputedRef, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From a given node list, finds a node that the mouse cursor is within its hit box.
|
||||||
|
* If more than one node meets this condition, returns the closest one.
|
||||||
|
*/
|
||||||
|
export function useCanvasNodeHover(
|
||||||
|
nodesRef: ComputedRef<CanvasNode[]>,
|
||||||
|
store: VueFlowStore,
|
||||||
|
getHitBox: (rect: Rect) => Rect,
|
||||||
|
) {
|
||||||
|
const id = ref<string | undefined>();
|
||||||
|
|
||||||
|
const recalculate = useThrottleFn(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
const bounds = store.viewportRef.value?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (!bounds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventCoord = store.project({
|
||||||
|
x: event.clientX - bounds.x,
|
||||||
|
y: event.clientY - bounds.y,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nearbyNodes = nodesRef.value
|
||||||
|
.flatMap((node) => {
|
||||||
|
if (node.data?.disabled) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const vueFlowNode = store.nodeLookup.value.get(node.id);
|
||||||
|
|
||||||
|
if (!vueFlowNode) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeRect = getRectOfNodes([vueFlowNode]);
|
||||||
|
const hitBox = getHitBox(nodeRect);
|
||||||
|
|
||||||
|
if (
|
||||||
|
hitBox.x > eventCoord.x ||
|
||||||
|
eventCoord.x > hitBox.x + hitBox.width ||
|
||||||
|
hitBox.y > eventCoord.y ||
|
||||||
|
eventCoord.y > hitBox.y + hitBox.height
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = nodeRect.x + nodeRect.width / 2 - eventCoord.x;
|
||||||
|
const dy = nodeRect.y + nodeRect.height / 2 - eventCoord.y;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: node.id,
|
||||||
|
squareDistance: dx ** 2 + dy ** 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.toSorted((nodeA, nodeB) => nodeA.squareDistance - nodeB.squareDistance);
|
||||||
|
|
||||||
|
id.value = nearbyNodes[0]?.id;
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.vueFlowRef.value?.addEventListener('mousemove', recalculate);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
store.vueFlowRef.value?.removeEventListener('mousemove', recalculate);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id };
|
||||||
|
}
|
|
@ -2822,6 +2822,32 @@ describe('useCanvasOperations', () => {
|
||||||
expect(workflowsStore.addConnection).not.toHaveBeenCalled();
|
expect(workflowsStore.addConnection).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toggleChatOpen', () => {
|
||||||
|
it('should invoke workflowsStore#setPanelOpen with 2nd argument `true` if the chat panel is closed', async () => {
|
||||||
|
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
const { toggleChatOpen } = useCanvasOperations({ router });
|
||||||
|
|
||||||
|
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
|
||||||
|
workflowsStore.isChatPanelOpen = false;
|
||||||
|
|
||||||
|
await toggleChatOpen('main');
|
||||||
|
|
||||||
|
expect(workflowsStore.setPanelOpen).toHaveBeenCalledWith('chat', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should invoke workflowsStore#setPanelOpen with 2nd argument `false` if the chat panel is open', async () => {
|
||||||
|
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
const { toggleChatOpen } = useCanvasOperations({ router });
|
||||||
|
|
||||||
|
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
|
||||||
|
workflowsStore.isChatPanelOpen = true;
|
||||||
|
|
||||||
|
await toggleChatOpen('main');
|
||||||
|
|
||||||
|
expect(workflowsStore.setPanelOpen).toHaveBeenCalledWith('chat', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildImportNodes() {
|
function buildImportNodes() {
|
||||||
|
|
|
@ -1936,6 +1936,20 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleChatOpen(source: 'node' | 'main') {
|
||||||
|
const workflow = workflowsStore.getCurrentWorkflow();
|
||||||
|
|
||||||
|
workflowsStore.setPanelOpen('chat', !workflowsStore.isChatPanelOpen);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
workflow_id: workflow.id,
|
||||||
|
button_type: source,
|
||||||
|
};
|
||||||
|
|
||||||
|
void externalHooks.run('nodeView.onOpenChat', payload);
|
||||||
|
telemetry.track('User clicked chat open button', payload);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lastClickPosition,
|
lastClickPosition,
|
||||||
editableWorkflow,
|
editableWorkflow,
|
||||||
|
@ -1982,5 +1996,6 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
initializeWorkspace,
|
initializeWorkspace,
|
||||||
resolveNodeWebhook,
|
resolveNodeWebhook,
|
||||||
openExecution,
|
openExecution,
|
||||||
|
toggleChatOpen,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,6 +75,7 @@ vi.mock('@/composables/useWorkflowHelpers', () => ({
|
||||||
getWorkflowDataToSave: vi.fn(),
|
getWorkflowDataToSave: vi.fn(),
|
||||||
setDocumentTitle: vi.fn(),
|
setDocumentTitle: vi.fn(),
|
||||||
executeData: vi.fn(),
|
executeData: vi.fn(),
|
||||||
|
getNodeTypes: vi.fn().mockReturnValue([]),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -402,6 +403,30 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should send triggerToStartFrom if triggerNode is passed in without nodeData', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
|
const triggerNode = 'Chat Trigger';
|
||||||
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(
|
||||||
|
mock<Workflow>({ getChildNodes: vi.fn().mockReturnValue([]) }),
|
||||||
|
);
|
||||||
|
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue(
|
||||||
|
mock<IWorkflowData>({ nodes: [] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await runWorkflow({ triggerNode });
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
triggerToStartFrom: {
|
||||||
|
name: triggerNode,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not use the original run data if `partialExecutionVersion` is set to 1', async () => {
|
it('does not use the original run data if `partialExecutionVersion` is set to 1', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
const mockExecutionResponse = { executionId: '123' };
|
const mockExecutionResponse = { executionId: '123' };
|
||||||
|
@ -595,4 +620,33 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
expect(result.runData).toEqual(undefined);
|
expect(result.runData).toEqual(undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('runEntireWorkflow()', () => {
|
||||||
|
it('should invoke runWorkflow with expected arguments', async () => {
|
||||||
|
const runWorkflowComposable = useRunWorkflow({ router });
|
||||||
|
|
||||||
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
|
||||||
|
id: 'workflowId',
|
||||||
|
} as unknown as Workflow);
|
||||||
|
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
|
||||||
|
id: 'workflowId',
|
||||||
|
nodes: [],
|
||||||
|
} as unknown as IWorkflowData);
|
||||||
|
|
||||||
|
await runWorkflowComposable.runEntireWorkflow('main', 'foo');
|
||||||
|
|
||||||
|
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith({
|
||||||
|
runData: undefined,
|
||||||
|
startNodes: [],
|
||||||
|
triggerToStartFrom: {
|
||||||
|
data: undefined,
|
||||||
|
name: 'foo',
|
||||||
|
},
|
||||||
|
workflowData: {
|
||||||
|
id: 'workflowId',
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -15,9 +15,10 @@ import type {
|
||||||
IRun,
|
IRun,
|
||||||
INode,
|
INode,
|
||||||
IDataObject,
|
IDataObject,
|
||||||
|
IWorkflowBase,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType, TelemetryHelpers } from 'n8n-workflow';
|
||||||
|
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
|
@ -35,6 +36,7 @@ import { isEmpty } from '@/utils/typesUtils';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { get } from 'lodash-es';
|
import { get } from 'lodash-es';
|
||||||
import { useExecutionsStore } from '@/stores/executions.store';
|
import { useExecutionsStore } from '@/stores/executions.store';
|
||||||
|
import { useTelemetry } from './useTelemetry';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
|
|
||||||
|
@ -62,6 +64,9 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
const workflowHelpers = useWorkflowHelpers({ router: useRunWorkflowOpts.router });
|
const workflowHelpers = useWorkflowHelpers({ router: useRunWorkflowOpts.router });
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
const externalHooks = useExternalHooks();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const pushConnectionStore = usePushConnectionStore();
|
const pushConnectionStore = usePushConnectionStore();
|
||||||
|
@ -168,7 +173,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
executedNode = options.triggerNode;
|
executedNode = options.triggerNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.triggerNode && options.nodeData) {
|
if (options.triggerNode) {
|
||||||
triggerToStartFrom = {
|
triggerToStartFrom = {
|
||||||
name: options.triggerNode,
|
name: options.triggerNode,
|
||||||
data: options.nodeData,
|
data: options.nodeData,
|
||||||
|
@ -307,6 +312,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
stoppedAt: undefined,
|
stoppedAt: undefined,
|
||||||
workflowId: workflow.id,
|
workflowId: workflow.id,
|
||||||
executedNode,
|
executedNode,
|
||||||
|
triggerNode: triggerToStartFrom?.name,
|
||||||
data: {
|
data: {
|
||||||
resultData: {
|
resultData: {
|
||||||
runData: startRunData.runData ?? {},
|
runData: startRunData.runData ?? {},
|
||||||
|
@ -352,7 +358,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
});
|
});
|
||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
|
|
||||||
await useExternalHooks().run('workflowRun.runWorkflow', {
|
await externalHooks.run('workflowRun.runWorkflow', {
|
||||||
nodeName: options.destinationNode,
|
nodeName: options.destinationNode,
|
||||||
source: options.source,
|
source: options.source,
|
||||||
});
|
});
|
||||||
|
@ -467,8 +473,31 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runEntireWorkflow(source: 'node' | 'main', triggerNode?: string) {
|
||||||
|
const workflow = workflowHelpers.getCurrentWorkflow();
|
||||||
|
|
||||||
|
void workflowHelpers.getWorkflowDataToSave().then((workflowData) => {
|
||||||
|
const telemetryPayload = {
|
||||||
|
workflow_id: workflow.id,
|
||||||
|
node_graph_string: JSON.stringify(
|
||||||
|
TelemetryHelpers.generateNodesGraph(
|
||||||
|
workflowData as IWorkflowBase,
|
||||||
|
workflowHelpers.getNodeTypes(),
|
||||||
|
{ isCloudDeployment: settingsStore.isCloudDeployment },
|
||||||
|
).nodeGraph,
|
||||||
|
),
|
||||||
|
button_type: source,
|
||||||
|
};
|
||||||
|
telemetry.track('User clicked execute workflow button', telemetryPayload);
|
||||||
|
void externalHooks.run('nodeView.onRunWorkflow', telemetryPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
void runWorkflow({ triggerNode });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
consolidateRunDataAndStartNodes,
|
consolidateRunDataAndStartNodes,
|
||||||
|
runEntireWorkflow,
|
||||||
runWorkflow,
|
runWorkflow,
|
||||||
runWorkflowApi,
|
runWorkflowApi,
|
||||||
stopCurrentExecution,
|
stopCurrentExecution,
|
||||||
|
|
|
@ -441,6 +441,7 @@ export const LOCAL_STORAGE_EXPERIMENT_OVERRIDES = 'N8N_EXPERIMENT_OVERRIDES';
|
||||||
export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_BUTTON';
|
export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_BUTTON';
|
||||||
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
|
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
|
||||||
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
|
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
|
||||||
|
export const LOCAL_STORAGE_CANVAS_TRIGGER_BUTTON_VARIANT = 'Canvas.TriggerButtonVariant';
|
||||||
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||||
|
|
||||||
export const HIRING_BANNER = `
|
export const HIRING_BANNER = `
|
||||||
|
|
|
@ -182,6 +182,7 @@
|
||||||
"binaryDataDisplay.noDataFoundToDisplay": "No data found to display",
|
"binaryDataDisplay.noDataFoundToDisplay": "No data found to display",
|
||||||
"binaryDataDisplay.yourBrowserDoesNotSupport": "Your browser does not support the video element. Kindly update it to latest version.",
|
"binaryDataDisplay.yourBrowserDoesNotSupport": "Your browser does not support the video element. Kindly update it to latest version.",
|
||||||
"chat.hide": "Hide chat",
|
"chat.hide": "Hide chat",
|
||||||
|
"chat.open": "Open chat",
|
||||||
"chat.window.title": "Chat",
|
"chat.window.title": "Chat",
|
||||||
"chat.window.logs": "Latest Logs",
|
"chat.window.logs": "Latest Logs",
|
||||||
"chat.window.logsFromNode": "from {nodeName} node",
|
"chat.window.logsFromNode": "from {nodeName} node",
|
||||||
|
|
|
@ -68,8 +68,8 @@ import {
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import { TelemetryHelpers, NodeConnectionType, jsonParse } from 'n8n-workflow';
|
import { NodeConnectionType, jsonParse } from 'n8n-workflow';
|
||||||
import type { IDataObject, ExecutionSummary, IConnection, IWorkflowBase } from 'n8n-workflow';
|
import type { IDataObject, ExecutionSummary, IConnection } from 'n8n-workflow';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
|
@ -166,7 +166,8 @@ const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings } = useBef
|
||||||
route,
|
route,
|
||||||
});
|
});
|
||||||
const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions();
|
const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions();
|
||||||
const { runWorkflow, stopCurrentExecution, stopWaitingForWebhook } = useRunWorkflow({ router });
|
const { runWorkflow, runEntireWorkflow, stopCurrentExecution, stopWaitingForWebhook } =
|
||||||
|
useRunWorkflow({ router });
|
||||||
const {
|
const {
|
||||||
updateNodePosition,
|
updateNodePosition,
|
||||||
updateNodesPosition,
|
updateNodesPosition,
|
||||||
|
@ -203,6 +204,7 @@ const {
|
||||||
editableWorkflow,
|
editableWorkflow,
|
||||||
editableWorkflowObject,
|
editableWorkflowObject,
|
||||||
lastClickPosition,
|
lastClickPosition,
|
||||||
|
toggleChatOpen,
|
||||||
} = useCanvasOperations({ router });
|
} = useCanvasOperations({ router });
|
||||||
const { applyExecutionData } = useExecutionDebugging();
|
const { applyExecutionData } = useExecutionDebugging();
|
||||||
useClipboard({ onPaste: onClipboardPaste });
|
useClipboard({ onPaste: onClipboardPaste });
|
||||||
|
@ -1104,29 +1106,6 @@ const isClearExecutionButtonVisible = computed(
|
||||||
|
|
||||||
const workflowExecutionData = computed(() => workflowsStore.workflowExecutionData);
|
const workflowExecutionData = computed(() => workflowsStore.workflowExecutionData);
|
||||||
|
|
||||||
async function onRunWorkflow() {
|
|
||||||
trackRunWorkflow();
|
|
||||||
|
|
||||||
void runWorkflow({});
|
|
||||||
}
|
|
||||||
|
|
||||||
function trackRunWorkflow() {
|
|
||||||
void workflowHelpers.getWorkflowDataToSave().then((workflowData) => {
|
|
||||||
const telemetryPayload = {
|
|
||||||
workflow_id: workflowId.value,
|
|
||||||
node_graph_string: JSON.stringify(
|
|
||||||
TelemetryHelpers.generateNodesGraph(
|
|
||||||
workflowData as IWorkflowBase,
|
|
||||||
workflowHelpers.getNodeTypes(),
|
|
||||||
{ isCloudDeployment: settingsStore.isCloudDeployment },
|
|
||||||
).nodeGraph,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
telemetry.track('User clicked execute workflow button', telemetryPayload);
|
|
||||||
void externalHooks.run('nodeView.onRunWorkflow', telemetryPayload);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onRunWorkflowToNode(id: string) {
|
async function onRunWorkflowToNode(id: string) {
|
||||||
const node = workflowsStore.getNodeById(id);
|
const node = workflowsStore.getNodeById(id);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
@ -1288,14 +1267,7 @@ const chatTriggerNodePinnedData = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onOpenChat() {
|
async function onOpenChat() {
|
||||||
workflowsStore.setPanelOpen('chat', !workflowsStore.isChatPanelOpen);
|
await toggleChatOpen('main');
|
||||||
|
|
||||||
const payload = {
|
|
||||||
workflow_id: workflowId.value,
|
|
||||||
};
|
|
||||||
|
|
||||||
void externalHooks.run('nodeView.onOpenChat', payload);
|
|
||||||
telemetry.track('User clicked chat open button', payload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1734,7 +1706,7 @@ onBeforeUnmount(() => {
|
||||||
@duplicate:nodes="onDuplicateNodes"
|
@duplicate:nodes="onDuplicateNodes"
|
||||||
@copy:nodes="onCopyNodes"
|
@copy:nodes="onCopyNodes"
|
||||||
@cut:nodes="onCutNodes"
|
@cut:nodes="onCutNodes"
|
||||||
@run:workflow="onRunWorkflow"
|
@run:workflow="runEntireWorkflow('main')"
|
||||||
@save:workflow="onSaveWorkflow"
|
@save:workflow="onSaveWorkflow"
|
||||||
@create:workflow="onCreateWorkflow"
|
@create:workflow="onCreateWorkflow"
|
||||||
@viewport-change="onViewportChange"
|
@viewport-change="onViewportChange"
|
||||||
|
@ -1751,12 +1723,12 @@ onBeforeUnmount(() => {
|
||||||
:executing="isWorkflowRunning"
|
:executing="isWorkflowRunning"
|
||||||
@mouseenter="onRunWorkflowButtonMouseEnter"
|
@mouseenter="onRunWorkflowButtonMouseEnter"
|
||||||
@mouseleave="onRunWorkflowButtonMouseLeave"
|
@mouseleave="onRunWorkflowButtonMouseLeave"
|
||||||
@click="onRunWorkflow"
|
@click="runEntireWorkflow('main')"
|
||||||
/>
|
/>
|
||||||
<CanvasChatButton
|
<CanvasChatButton
|
||||||
v-if="containsChatTriggerNodes"
|
v-if="containsChatTriggerNodes"
|
||||||
:type="isChatOpen ? 'tertiary' : 'primary'"
|
:type="isChatOpen ? 'tertiary' : 'primary'"
|
||||||
:label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.window.title')"
|
:label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
|
||||||
@click="onOpenChat"
|
@click="onOpenChat"
|
||||||
/>
|
/>
|
||||||
<CanvasStopCurrentExecutionButton
|
<CanvasStopCurrentExecutionButton
|
||||||
|
|
|
@ -4691,7 +4691,7 @@ export default defineComponent({
|
||||||
|
|
||||||
<n8n-button
|
<n8n-button
|
||||||
v-if="containsChatNodes"
|
v-if="containsChatNodes"
|
||||||
:label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.window.title')"
|
:label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
|
||||||
size="large"
|
size="large"
|
||||||
icon="comment"
|
icon="comment"
|
||||||
:type="isChatOpen ? 'tertiary' : 'primary'"
|
:type="isChatOpen ? 'tertiary' : 'primary'"
|
||||||
|
|
Loading…
Reference in a new issue