mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Add ‘execute workflow’ buttons below triggers on the canvas (#12769)
Co-authored-by: Danny Martini <danny@n8n.io> Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
This commit is contained in:
parent
e92556260f
commit
b17cbec3af
|
@ -4,6 +4,10 @@
|
|||
|
||||
import { getVisiblePopper, getVisibleSelect } from '../utils/popper';
|
||||
|
||||
export function getNdvContainer() {
|
||||
return cy.getByTestId('ndv');
|
||||
}
|
||||
|
||||
export function getCredentialSelect(eq = 0) {
|
||||
return cy.getByTestId('node-credentials-select').eq(eq);
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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');
|
||||
|
|
76
cypress/fixtures/Two_schedule_triggers.json
Normal file
76
cypress/fixtures/Two_schedule_triggers.json
Normal file
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "6a8c3d85-26f8-4f28-ace9-55a196a23d37",
|
||||
"name": "prevNode",
|
||||
"value": "={{ $prevNode.name }}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [200, -100],
|
||||
"id": "351ce967-0399-4a78-848a-9cc69b831796",
|
||||
"name": "Edit Fields"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [{}]
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.2,
|
||||
"position": [0, -100],
|
||||
"id": "cf2f58a8-1fbb-4c70-b2b1-9e06bee7ec47",
|
||||
"name": "Trigger A"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [{}]
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.2,
|
||||
"position": [0, 100],
|
||||
"id": "4fade34e-2bfc-4a2e-a8ed-03ab2ed9c690",
|
||||
"name": "Trigger B"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Trigger A": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Trigger B": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
"meta": {
|
||||
"instanceId": "0dd4627b77a5a795ab9bf073e5812be94dd8d1a5f012248ef2a4acac09be12cb"
|
||||
}
|
||||
}
|
|
@ -46,5 +46,26 @@ describe('ManualExecutionService', () => {
|
|||
name: 'node2',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should return triggerToStartFrom trigger node', () => {
|
||||
const data = {
|
||||
pinData: {
|
||||
node1: {},
|
||||
node2: {},
|
||||
},
|
||||
triggerToStartFrom: { name: 'node3' },
|
||||
} as unknown as IWorkflowExecutionDataProcess;
|
||||
const workflow = {
|
||||
getNode(nodeName: string) {
|
||||
return {
|
||||
name: nodeName,
|
||||
};
|
||||
},
|
||||
} as unknown as Workflow;
|
||||
const executionStartNode = manualExecutionService.getExecutionStartNode(data, workflow);
|
||||
expect(executionStartNode).toEqual({
|
||||
name: 'node3',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -23,6 +23,13 @@ export class ManualExecutionService {
|
|||
|
||||
getExecutionStartNode(data: IWorkflowExecutionDataProcess, workflow: Workflow) {
|
||||
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)
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -405,6 +405,7 @@ export interface IExecutionResponse extends IExecutionBase {
|
|||
data?: IRunExecutionData;
|
||||
workflowData: IWorkflowDb;
|
||||
executedNode?: string;
|
||||
triggerNode?: string;
|
||||
}
|
||||
|
||||
export type ExecutionSummaryWithScopes = ExecutionSummary & { scopes: Scope[] };
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -0,0 +1,163 @@
|
|||
<script lang="ts" setup>
|
||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||
import { CHAT_TRIGGER_NODE_TYPE, LOCAL_STORAGE_CANVAS_TRIGGER_BUTTON_VARIANT } from '@/constants';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { computed, useCssModule } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
hovered,
|
||||
disabled,
|
||||
class: cls,
|
||||
} = defineProps<{
|
||||
name: string;
|
||||
type: string;
|
||||
hovered?: boolean;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
const variant = useLocalStorage<1 | 2>(LOCAL_STORAGE_CANVAS_TRIGGER_BUTTON_VARIANT, 2);
|
||||
|
||||
const style = useCssModule();
|
||||
const containerClass = computed(() => ({
|
||||
[cls ?? '']: true,
|
||||
[style.container]: true,
|
||||
[style.interactive]: !disabled,
|
||||
[style.hovered]: !!hovered,
|
||||
[style.variant1]: variant.value === 1,
|
||||
[style.variant2]: variant.value === 2,
|
||||
}));
|
||||
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const uiStore = useUIStore();
|
||||
const { runEntireWorkflow } = useRunWorkflow({ router });
|
||||
const { toggleChatOpen } = useCanvasOperations({ router });
|
||||
|
||||
const isChatOpen = computed(() => workflowsStore.isChatPanelOpen);
|
||||
const isExecuting = computed(() => uiStore.isActionActive.workflowRunning);
|
||||
const testId = computed(() => `execute-workflow-button-${name}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- click and mousedown event are suppressed to avoid unwanted selection or dragging of the node -->
|
||||
<div :class="containerClass" @click.stop.prevent @mousedown.stop.prevent>
|
||||
<div>
|
||||
<div v-if="variant === 2" :class="$style.bolt">
|
||||
<FontAwesomeIcon icon="bolt" size="lg" />
|
||||
</div>
|
||||
|
||||
<N8nButton
|
||||
v-if="variant === 1 && type === CHAT_TRIGGER_NODE_TYPE"
|
||||
type="secondary"
|
||||
size="large"
|
||||
:disabled="isExecuting"
|
||||
:data-test-id="testId"
|
||||
@click.capture="toggleChatOpen('node')"
|
||||
>{{ isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open') }}</N8nButton
|
||||
>
|
||||
<N8nButton
|
||||
v-else-if="variant === 1"
|
||||
type="secondary"
|
||||
size="large"
|
||||
:disabled="isExecuting"
|
||||
:data-test-id="testId"
|
||||
@click.capture="runEntireWorkflow('node', name)"
|
||||
>{{ i18n.baseText('nodeView.runButtonText.executeWorkflow') }}</N8nButton
|
||||
>
|
||||
<N8nButton
|
||||
v-else-if="variant === 2 && type === CHAT_TRIGGER_NODE_TYPE"
|
||||
:type="isChatOpen ? 'secondary' : 'primary'"
|
||||
size="large"
|
||||
:disabled="isExecuting"
|
||||
:data-test-id="testId"
|
||||
:label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
|
||||
@click.capture="toggleChatOpen('node')"
|
||||
/>
|
||||
<N8nButton
|
||||
v-else
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="isExecuting"
|
||||
:data-test-id="testId"
|
||||
:label="i18n.baseText('nodeView.runButtonText.executeWorkflow')"
|
||||
@click.capture="runEntireWorkflow('node', name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
right: 100%;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
|
||||
& > div {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.hovered button {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.variant1 {
|
||||
& button {
|
||||
margin-right: var(--spacing-xl);
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.interactive button {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.variant2 {
|
||||
& button {
|
||||
margin-right: var(--spacing-s);
|
||||
opacity: 0;
|
||||
translate: -12px 0;
|
||||
transition:
|
||||
translate 0.1s ease-in,
|
||||
opacity 0.1s ease-in;
|
||||
}
|
||||
|
||||
&.interactive.hovered button {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bolt {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
color: var(--color-primary);
|
||||
padding: var(--spacing-s);
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
transition:
|
||||
translate 0.1s ease-in,
|
||||
opacity 0.1s ease-in;
|
||||
|
||||
.container.interactive.hovered & {
|
||||
translate: -12px 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,35 +0,0 @@
|
|||
import CanvasNodeTriggerIcon from './CanvasNodeTriggerIcon.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
||||
vi.mock('@/composables/useI18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
baseText: vi.fn().mockReturnValue('This is a trigger node'),
|
||||
})),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasNodeTriggerIcon, {
|
||||
global: {
|
||||
stubs: {
|
||||
FontAwesomeIcon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('CanvasNodeTriggerIcon', () => {
|
||||
it('should render trigger icon with tooltip', () => {
|
||||
const { container } = renderComponent();
|
||||
|
||||
expect(container.querySelector('.triggerIcon')).toBeInTheDocument();
|
||||
|
||||
const icon = container.querySelector('font-awesome-icon-stub');
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon?.getAttribute('icon')).toBe('bolt');
|
||||
expect(icon?.getAttribute('size')).toBe('lg');
|
||||
});
|
||||
|
||||
it('should render tooltip with correct content', () => {
|
||||
const { getByText } = renderComponent();
|
||||
|
||||
expect(getByText('This is a trigger node')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -1,26 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const i18n = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nTooltip placement="bottom">
|
||||
<template #content>
|
||||
<span v-n8n-html="i18n.baseText('node.thisIsATriggerNode')" />
|
||||
</template>
|
||||
<div :class="$style.triggerIcon">
|
||||
<FontAwesomeIcon icon="bolt" size="lg" />
|
||||
</div>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.triggerIcon {
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
margin: auto;
|
||||
color: var(--color-primary);
|
||||
padding: var(--spacing-2xs);
|
||||
}
|
||||
</style>
|
|
@ -133,16 +133,6 @@ export function useCanvasMapping({
|
|||
}, {}),
|
||||
);
|
||||
|
||||
const activeTriggerNodeCount = computed(
|
||||
() =>
|
||||
nodes.value.filter(
|
||||
(node) =>
|
||||
nodeTypeDescriptionByNodeId.value[node.id]?.eventTriggerDescription !== '' &&
|
||||
isTriggerNodeById.value[node.id] &&
|
||||
!node.disabled,
|
||||
).length,
|
||||
);
|
||||
|
||||
const nodeSubtitleById = computed(() => {
|
||||
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) => {
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
/* eslint-disable vue/one-component-per-file */
|
||||
import { renderComponent } from '@/__tests__/render';
|
||||
import { type CanvasNode } from '@/types';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { type Rect, useVueFlow, VueFlow } from '@vue-flow/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computed, defineComponent, h } from 'vue';
|
||||
import { useCanvasNodeHover } from './useCanvasNodeHover';
|
||||
|
||||
describe(useCanvasNodeHover, () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function getHitBoxMargin10(rect: Rect): Rect {
|
||||
return {
|
||||
x: rect.x - 10,
|
||||
y: rect.y - 10,
|
||||
width: rect.width + 20,
|
||||
height: rect.height + 20,
|
||||
};
|
||||
}
|
||||
|
||||
function getHitBoxMargin100(rect: Rect): Rect {
|
||||
return {
|
||||
x: rect.x - 100,
|
||||
y: rect.y - 100,
|
||||
width: rect.width + 200,
|
||||
height: rect.height + 200,
|
||||
};
|
||||
}
|
||||
|
||||
const nodesRef = computed<CanvasNode[]>(() => [
|
||||
{ id: 'node-1', position: { x: 100, y: 100 } },
|
||||
{ id: 'node-2', position: { x: 100, y: 200 } },
|
||||
]);
|
||||
|
||||
it('should return ID of the node which a mousemove event was emitted on', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const store = useVueFlow();
|
||||
return useCanvasNodeHover(nodesRef, store, getHitBoxMargin10);
|
||||
},
|
||||
render() {
|
||||
return h('div', [
|
||||
h('div', { 'data-test-id': 'hovered' }, this.id ?? 'no match'),
|
||||
h(VueFlow, { 'data-test-id': 'canvas', nodes: nodesRef.value }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const wrapper = renderComponent(TestComponent);
|
||||
|
||||
expect(wrapper.getByTestId('hovered')).toHaveTextContent('no match');
|
||||
|
||||
fireEvent.mouseMove(wrapper.getByTestId('canvas'), { clientX: 90, clientY: 90 });
|
||||
await wrapper.rerender({});
|
||||
expect(wrapper.getByTestId('hovered')).toHaveTextContent('node-1');
|
||||
|
||||
vi.advanceTimersByTime(1000); // Advance timer to circumvent throttling
|
||||
|
||||
fireEvent.mouseMove(wrapper.getByTestId('canvas'), { clientX: 110, clientY: 210 });
|
||||
await wrapper.rerender({});
|
||||
expect(wrapper.getByTestId('hovered')).toHaveTextContent('node-2');
|
||||
|
||||
vi.advanceTimersByTime(1000); // Advance timer to circumvent throttling
|
||||
|
||||
fireEvent.mouseMove(wrapper.getByTestId('canvas'), { clientX: 0, clientY: 0 });
|
||||
await wrapper.rerender({});
|
||||
expect(wrapper.getByTestId('hovered')).toHaveTextContent('no match');
|
||||
});
|
||||
|
||||
it('should return ID of the closest node if more than one node exist near the coordinate of mousemove event', async () => {
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const store = useVueFlow();
|
||||
return useCanvasNodeHover(nodesRef, store, getHitBoxMargin100);
|
||||
},
|
||||
render() {
|
||||
return h('div', [
|
||||
h('div', { 'data-test-id': 'hovered' }, this.id ?? 'no match'),
|
||||
h(VueFlow, { 'data-test-id': 'canvas', nodes: nodesRef.value }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const wrapper = renderComponent(TestComponent);
|
||||
|
||||
fireEvent.mouseMove(wrapper.getByTestId('canvas'), { clientX: 100, clientY: 160 });
|
||||
await wrapper.rerender({});
|
||||
expect(wrapper.getByTestId('hovered')).toHaveTextContent('node-2');
|
||||
});
|
||||
});
|
82
packages/editor-ui/src/composables/useCanvasNodeHover.ts
Normal file
82
packages/editor-ui/src/composables/useCanvasNodeHover.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
import { type CanvasNode } from '@/types';
|
||||
import { getRectOfNodes, type Rect, type VueFlowStore } from '@vue-flow/core';
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { type ComputedRef, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
/**
|
||||
* From a given node list, finds a node that the mouse cursor is within its hit box.
|
||||
* If more than one node meets this condition, returns the closest one.
|
||||
*/
|
||||
export function useCanvasNodeHover(
|
||||
nodesRef: ComputedRef<CanvasNode[]>,
|
||||
store: VueFlowStore,
|
||||
getHitBox: (rect: Rect) => Rect,
|
||||
) {
|
||||
const id = ref<string | undefined>();
|
||||
|
||||
const recalculate = useThrottleFn(
|
||||
(event: MouseEvent) => {
|
||||
const bounds = store.viewportRef.value?.getBoundingClientRect();
|
||||
|
||||
if (!bounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventCoord = store.project({
|
||||
x: event.clientX - bounds.x,
|
||||
y: event.clientY - bounds.y,
|
||||
});
|
||||
|
||||
const nearbyNodes = nodesRef.value
|
||||
.flatMap((node) => {
|
||||
if (node.data?.disabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const vueFlowNode = store.nodeLookup.value.get(node.id);
|
||||
|
||||
if (!vueFlowNode) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodeRect = getRectOfNodes([vueFlowNode]);
|
||||
const hitBox = getHitBox(nodeRect);
|
||||
|
||||
if (
|
||||
hitBox.x > eventCoord.x ||
|
||||
eventCoord.x > hitBox.x + hitBox.width ||
|
||||
hitBox.y > eventCoord.y ||
|
||||
eventCoord.y > hitBox.y + hitBox.height
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dx = nodeRect.x + nodeRect.width / 2 - eventCoord.x;
|
||||
const dy = nodeRect.y + nodeRect.height / 2 - eventCoord.y;
|
||||
|
||||
return [
|
||||
{
|
||||
id: node.id,
|
||||
squareDistance: dx ** 2 + dy ** 2,
|
||||
},
|
||||
];
|
||||
})
|
||||
.toSorted((nodeA, nodeB) => nodeA.squareDistance - nodeB.squareDistance);
|
||||
|
||||
id.value = nearbyNodes[0]?.id;
|
||||
},
|
||||
200,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.vueFlowRef.value?.addEventListener('mousemove', recalculate);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
store.vueFlowRef.value?.removeEventListener('mousemove', recalculate);
|
||||
});
|
||||
|
||||
return { id };
|
||||
}
|
|
@ -2822,6 +2822,32 @@ describe('useCanvasOperations', () => {
|
|||
expect(workflowsStore.addConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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() {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = `
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'"
|
||||
|
|
Loading…
Reference in a new issue