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:
autologie 2025-02-10 09:14:39 +01:00 committed by GitHub
parent e92556260f
commit b17cbec3af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 957 additions and 168 deletions

View file

@ -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);
} }

View file

@ -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() {

View file

@ -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');

View 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"
}
}

View file

@ -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',
});
});
}); });
}); });

View file

@ -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)

View file

@ -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: [],

View file

@ -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 &&

View file

@ -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[] };

View file

@ -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"

View file

@ -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 {

View file

@ -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>

View file

@ -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

View file

@ -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 {

View file

@ -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>

View file

@ -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();
});
});

View file

@ -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>

View file

@ -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) => {

View file

@ -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');
});
});

View 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 };
}

View file

@ -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() {

View file

@ -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,
}; };
} }

View file

@ -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: [],
},
});
});
});
}); });

View file

@ -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,

View file

@ -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 = `

View file

@ -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",

View file

@ -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

View file

@ -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'"