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';
export function getNdvContainer() {
return cy.getByTestId('ndv');
}
export function getCredentialSelect(eq = 0) {
return cy.getByTestId('node-credentials-select').eq(eq);
}

View file

@ -101,8 +101,8 @@ export function getNodeCreatorItems() {
return cy.getByTestId('item-iterator-item');
}
export function getExecuteWorkflowButton() {
return cy.getByTestId('execute-workflow-button');
export function getExecuteWorkflowButton(triggerNodeName?: string) {
return cy.getByTestId(`execute-workflow-button${triggerNodeName ? `-${triggerNodeName}` : ''}`);
}
export function getManualChatButton() {
@ -294,8 +294,8 @@ export function addRetrieverNodeToParent(nodeName: string, parentNodeName: strin
addSupplementalNodeToParent(nodeName, 'ai_retriever', parentNodeName);
}
export function clickExecuteWorkflowButton() {
getExecuteWorkflowButton().click();
export function clickExecuteWorkflowButton(triggerNodeName?: string) {
getExecuteWorkflowButton(triggerNodeName).click();
}
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 { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
import { clearNotifications, errorToast, successToast } from '../pages/notifications';
@ -214,6 +222,39 @@ describe('Execution', () => {
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', () => {
it('when deleting the last execution, it should show empty state', () => {
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',
});
});
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) {
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 (
data.startNodes?.length === 1 &&
Object.keys(data.pinData ?? {}).includes(data.startNodes[0].name)

View file

@ -1,11 +1,15 @@
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 { IWorkflowDb } from '@/interfaces';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import type { WorkflowRunner } from '@/workflow-runner';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import type { WorkflowRequest } from '../workflow.request';
const webhookNode: INode = {
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
@ -63,6 +67,9 @@ describe('WorkflowExecutionService', () => {
mock(),
);
const additionalData = mock<IWorkflowExecuteAdditionalData>({});
jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(additionalData);
describe('runWorkflow()', () => {
test('should call `WorkflowRunner.run()`', async () => {
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()', () => {
const workflow = mock<IWorkflowDb>({
nodes: [],

View file

@ -100,12 +100,18 @@ export class WorkflowExecutionService {
partialExecutionVersion: 1 | 2 = 1,
) {
const pinData = workflowData.pinData;
const pinnedTrigger = this.selectPinnedActivatorStarter(
let pinnedTrigger = this.selectPinnedActivatorStarter(
workflowData,
startNodes?.map((nodeData) => nodeData.name),
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 (
pinnedTrigger === null &&

View file

@ -405,6 +405,7 @@ export interface IExecutionResponse extends IExecutionBase {
data?: IRunExecutionData;
workflowData: IWorkflowDb;
executedNode?: string;
triggerNode?: string;
}
export type ExecutionSummaryWithScopes = ExecutionSummary & { scopes: Scope[] };

View file

@ -1,10 +1,11 @@
<script lang="ts" setup>
import type {
CanvasConnection,
CanvasNode,
CanvasNodeMoveEvent,
CanvasEventBusEvents,
ConnectStartEvent,
import {
type CanvasConnection,
type CanvasNode,
type CanvasNodeMoveEvent,
type CanvasEventBusEvents,
type ConnectStartEvent,
CanvasNodeRenderType,
} from '@/types';
import type {
Connection,
@ -34,6 +35,7 @@ import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
import CanvasBackground from './elements/background/CanvasBackground.vue';
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
import { NodeConnectionType } from 'n8n-workflow';
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
const $style = useCssModule();
@ -257,6 +259,20 @@ const hasSelection = computed(() => selectedNodes.value.length > 0);
const selectedNodeIds = computed(() => selectedNodes.value.map((node) => node.id));
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) => {
if (!lastSelectedNode.value || !nodes.find((node) => node.id === lastSelectedNode.value?.id)) {
lastSelectedNode.value = nodes[nodes.length - 1];
@ -710,6 +726,7 @@ provide(CanvasKey, {
:read-only="readOnly"
:event-bus="eventBus"
:hovered="nodesHoveredById[nodeProps.id]"
:nearby-hovered="nodeProps.id === hoveredTriggerNode.id.value"
@delete="onDeleteNode"
@run="onRunNode"
@select="onSelectNode"

View file

@ -16,7 +16,7 @@ import type {
CanvasNodeEventBusEvents,
CanvasEventBusEvents,
} from '@/types';
import { CanvasNodeRenderType, CanvasConnectionMode } from '@/types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import NodeIcon from '@/components/NodeIcon.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
@ -35,11 +35,13 @@ import {
import type { EventBus } from 'n8n-design-system';
import { createEventBus } from 'n8n-design-system';
import { isEqual } from 'lodash-es';
import CanvasNodeTrigger from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeTrigger.vue';
type Props = NodeProps<CanvasNodeData> & {
readOnly?: boolean;
eventBus?: EventBus<CanvasEventBusEvents>;
hovered?: boolean;
nearbyHovered?: boolean;
};
const slots = defineSlots<{
@ -406,12 +408,23 @@ onBeforeUnmount(() => {
/>
<!-- @TODO :color-default="iconColorDefault"-->
</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>
</template>
<style lang="scss" module>
.canvasNode {
&:hover,
&:hover:not(:has(> .trigger:hover)), // exclude .trigger which has extended hit zone
&:focus-within,
&.showToolbar {
.canvasNodeToolbar {

View file

@ -3,9 +3,14 @@ import { computed, ref, useCssModule, watch } from 'vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { useI18n } from '@/composables/useI18n';
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 { useCanvas } from '@/composables/useCanvas';
import { useLocalStorage } from '@vueuse/core';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
const $style = useCssModule();
const i18n = useI18n();
@ -106,6 +111,8 @@ const isStrikethroughVisible = computed(() => {
const showTooltip = ref(false);
const triggerButtonVariant = useLocalStorage<1 | 2>(LOCAL_STORAGE_CANVAS_TRIGGER_BUTTON_VARIANT, 2);
watch(initialized, () => {
if (initialized.value) {
showTooltip.value = true;
@ -128,12 +135,14 @@ function openContextMenu(event: MouseEvent) {
<div :class="classes" :style="styles" :data-test-id="dataTestId" @contextmenu="openContextMenu">
<CanvasNodeTooltip v-if="renderOptions.tooltip" :visible="showTooltip" />
<slot />
<CanvasNodeTriggerIcon v-if="renderOptions.trigger" />
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
<div :class="$style.description">
<div v-if="label" :class="$style.label">
{{ label }}
<div v-if="renderOptions.trigger && triggerButtonVariant === 1" :class="$style.triggerIcon">
<FontAwesomeIcon icon="bolt" size="lg" />
</div>
</div>
<div v-if="isDisabled" :class="$style.disabledLabel">
({{ i18n.baseText('node.disabled') }})
@ -313,4 +322,11 @@ function openContextMenu(event: MouseEvent) {
bottom: 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>

View file

@ -9,7 +9,6 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
<div
@ -19,6 +18,7 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
class="label"
>
Test Node
<!--v-if-->
</div>
<!--v-if-->
<div
@ -39,7 +39,6 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
<div
@ -49,6 +48,7 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
class="label"
>
Test Node
<!--v-if-->
</div>
<!--v-if-->
<div
@ -69,7 +69,6 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
<div
@ -79,6 +78,7 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
class="label"
>
Test Node
<!--v-if-->
</div>
<!--v-if-->
<div
@ -99,7 +99,6 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
<div
@ -109,6 +108,7 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
class="label"
>
Test Node
<!--v-if-->
</div>
<!--v-if-->
<div
@ -129,30 +129,6 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
<!--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-->
<div
@ -162,6 +138,7 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
class="label"
>
Test Node
<!--v-if-->
</div>
<!--v-if-->
<div

View file

@ -10,6 +10,12 @@ defineProps<{
const { render } = useCanvasNode();
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
const popperOptions = {
modifiers: [
{ name: 'flip', enabled: false }, // show tooltip always above the node
],
};
</script>
<template>
@ -19,6 +25,7 @@ const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRe
:visible="true"
:teleported="false"
:popper-class="$style.popper"
:popper-options="popperOptions"
>
<template #content>
{{ renderOptions.tooltip }}
@ -33,7 +40,7 @@ const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRe
top: 0;
left: 0;
width: 100%;
height: 1px;
height: 100%;
}
.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(() => {
return nodes.value.reduce<Record<string, string>>((acc, node) => {
try {
@ -255,13 +245,28 @@ export function useCanvasMapping({
}, {}),
);
const nodeTooltipById = computed(() =>
nodes.value.reduce<Record<string, string | undefined>>((acc, node) => {
const nodeTooltipById = computed(() => {
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];
if (nodeTypeDescription && isTriggerNodeById.value[node.id]) {
if (
activeTriggerNodeCount.value !== 1 ||
!workflowsStore.isWorkflowRunning ||
!!node.disabled ||
(triggerNodeName !== undefined && triggerNodeName !== node.name) ||
!['new', 'unknown', 'waiting'].includes(nodeExecutionStatusById.value[node.id])
) {
return acc;
@ -283,8 +288,8 @@ export function useCanvasMapping({
}
return acc;
}, {}),
);
}, {});
});
const nodeExecutionRunningById = computed(() =>
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();
});
});
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() {

View file

@ -1936,6 +1936,20 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
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 {
lastClickPosition,
editableWorkflow,
@ -1982,5 +1996,6 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
initializeWorkspace,
resolveNodeWebhook,
openExecution,
toggleChatOpen,
};
}

View file

@ -75,6 +75,7 @@ vi.mock('@/composables/useWorkflowHelpers', () => ({
getWorkflowDataToSave: vi.fn(),
setDocumentTitle: 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 () => {
// ARRANGE
const mockExecutionResponse = { executionId: '123' };
@ -595,4 +620,33 @@ describe('useRunWorkflow({ router })', () => {
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,
INode,
IDataObject,
IWorkflowBase,
} from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { NodeConnectionType, TelemetryHelpers } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
@ -35,6 +36,7 @@ import { isEmpty } from '@/utils/typesUtils';
import { useI18n } from '@/composables/useI18n';
import { get } from 'lodash-es';
import { useExecutionsStore } from '@/stores/executions.store';
import { useTelemetry } from './useTelemetry';
import { useSettingsStore } from '@/stores/settings.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 i18n = useI18n();
const toast = useToast();
const telemetry = useTelemetry();
const externalHooks = useExternalHooks();
const settingsStore = useSettingsStore();
const rootStore = useRootStore();
const pushConnectionStore = usePushConnectionStore();
@ -168,7 +173,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
executedNode = options.triggerNode;
}
if (options.triggerNode && options.nodeData) {
if (options.triggerNode) {
triggerToStartFrom = {
name: options.triggerNode,
data: options.nodeData,
@ -307,6 +312,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
stoppedAt: undefined,
workflowId: workflow.id,
executedNode,
triggerNode: triggerToStartFrom?.name,
data: {
resultData: {
runData: startRunData.runData ?? {},
@ -352,7 +358,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
});
} catch (error) {}
await useExternalHooks().run('workflowRun.runWorkflow', {
await externalHooks.run('workflowRun.runWorkflow', {
nodeName: options.destinationNode,
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 {
consolidateRunDataAndStartNodes,
runEntireWorkflow,
runWorkflow,
runWorkflowApi,
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_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_CANVAS_TRIGGER_BUTTON_VARIANT = 'Canvas.TriggerButtonVariant';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
export const HIRING_BANNER = `

View file

@ -182,6 +182,7 @@
"binaryDataDisplay.noDataFoundToDisplay": "No data found to display",
"binaryDataDisplay.yourBrowserDoesNotSupport": "Your browser does not support the video element. Kindly update it to latest version.",
"chat.hide": "Hide chat",
"chat.open": "Open chat",
"chat.window.title": "Chat",
"chat.window.logs": "Latest Logs",
"chat.window.logsFromNode": "from {nodeName} node",

View file

@ -68,8 +68,8 @@ import {
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { TelemetryHelpers, NodeConnectionType, jsonParse } from 'n8n-workflow';
import type { IDataObject, ExecutionSummary, IConnection, IWorkflowBase } from 'n8n-workflow';
import { NodeConnectionType, jsonParse } from 'n8n-workflow';
import type { IDataObject, ExecutionSummary, IConnection } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useSettingsStore } from '@/stores/settings.store';
import { useCredentialsStore } from '@/stores/credentials.store';
@ -166,7 +166,8 @@ const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings } = useBef
route,
});
const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions();
const { runWorkflow, stopCurrentExecution, stopWaitingForWebhook } = useRunWorkflow({ router });
const { runWorkflow, runEntireWorkflow, stopCurrentExecution, stopWaitingForWebhook } =
useRunWorkflow({ router });
const {
updateNodePosition,
updateNodesPosition,
@ -203,6 +204,7 @@ const {
editableWorkflow,
editableWorkflowObject,
lastClickPosition,
toggleChatOpen,
} = useCanvasOperations({ router });
const { applyExecutionData } = useExecutionDebugging();
useClipboard({ onPaste: onClipboardPaste });
@ -1104,29 +1106,6 @@ const isClearExecutionButtonVisible = computed(
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) {
const node = workflowsStore.getNodeById(id);
if (!node) return;
@ -1288,14 +1267,7 @@ const chatTriggerNodePinnedData = computed(() => {
});
async function onOpenChat() {
workflowsStore.setPanelOpen('chat', !workflowsStore.isChatPanelOpen);
const payload = {
workflow_id: workflowId.value,
};
void externalHooks.run('nodeView.onOpenChat', payload);
telemetry.track('User clicked chat open button', payload);
await toggleChatOpen('main');
}
/**
@ -1734,7 +1706,7 @@ onBeforeUnmount(() => {
@duplicate:nodes="onDuplicateNodes"
@copy:nodes="onCopyNodes"
@cut:nodes="onCutNodes"
@run:workflow="onRunWorkflow"
@run:workflow="runEntireWorkflow('main')"
@save:workflow="onSaveWorkflow"
@create:workflow="onCreateWorkflow"
@viewport-change="onViewportChange"
@ -1751,12 +1723,12 @@ onBeforeUnmount(() => {
:executing="isWorkflowRunning"
@mouseenter="onRunWorkflowButtonMouseEnter"
@mouseleave="onRunWorkflowButtonMouseLeave"
@click="onRunWorkflow"
@click="runEntireWorkflow('main')"
/>
<CanvasChatButton
v-if="containsChatTriggerNodes"
: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"
/>
<CanvasStopCurrentExecutionButton

View file

@ -4691,7 +4691,7 @@ export default defineComponent({
<n8n-button
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"
icon="comment"
:type="isChatOpen ? 'tertiary' : 'primary'"