feat(editor): Add support for fallback nodes and new addNodes node render type in new canvas (no-changelog) (#10004)

This commit is contained in:
Alex Grozav 2024-07-11 13:03:46 +03:00 committed by GitHub
parent f9e9d274b9
commit 57dfefd0f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 509 additions and 224 deletions

View file

@ -1,6 +1,7 @@
import { CanvasNodeKey } from '@/constants'; import { CanvasNodeKey } from '@/constants';
import { ref } from 'vue'; import { ref } from 'vue';
import type { CanvasElement, CanvasElementData } from '@/types'; import type { CanvasNode, CanvasNodeData } from '@/types';
import { CanvasNodeRenderType } from '@/types';
export function createCanvasNodeData({ export function createCanvasNodeData({
id = 'node', id = 'node',
@ -15,10 +16,10 @@ export function createCanvasNodeData({
pinnedData = { count: 0, visible: false }, pinnedData = { count: 0, visible: false },
runData = { count: 0, visible: false }, runData = { count: 0, visible: false },
render = { render = {
type: 'default', type: CanvasNodeRenderType.Default,
options: { configurable: false, configuration: false, trigger: false }, options: { configurable: false, configuration: false, trigger: false },
}, },
}: Partial<CanvasElementData> = {}): CanvasElementData { }: Partial<CanvasNodeData> = {}): CanvasNodeData {
return { return {
execution, execution,
issues, issues,
@ -41,9 +42,7 @@ export function createCanvasNodeElement({
label = 'Node', label = 'Node',
position = { x: 100, y: 100 }, position = { x: 100, y: 100 },
data, data,
}: Partial< }: Partial<Omit<CanvasNode, 'data'> & { data: Partial<CanvasNodeData> }> = {}): CanvasNode {
Omit<CanvasElement, 'data'> & { data: Partial<CanvasElementData> }
> = {}): CanvasElement {
return { return {
id, id,
type, type,
@ -58,7 +57,7 @@ export function createCanvasNodeProps({
label = 'Test Node', label = 'Test Node',
selected = false, selected = false,
data = {}, data = {},
}: { id?: string; label?: string; selected?: boolean; data?: Partial<CanvasElementData> } = {}) { }: { id?: string; label?: string; selected?: boolean; data?: Partial<CanvasNodeData> } = {}) {
return { return {
id, id,
label, label,
@ -72,7 +71,7 @@ export function createCanvasNodeProvide({
label = 'Test Node', label = 'Test Node',
selected = false, selected = false,
data = {}, data = {},
}: { id?: string; label?: string; selected?: boolean; data?: Partial<CanvasElementData> } = {}) { }: { id?: string; label?: string; selected?: boolean; data?: Partial<CanvasNodeData> } = {}) {
const props = createCanvasNodeProps({ id, label, selected, data }); const props = createCanvasNodeProps({ id, label, selected, data });
return { return {
[`${CanvasNodeKey}`]: { [`${CanvasNodeKey}`]: {
@ -85,8 +84,8 @@ export function createCanvasNodeProvide({
} }
export function createCanvasConnection( export function createCanvasConnection(
nodeA: CanvasElement, nodeA: CanvasNode,
nodeB: CanvasElement, nodeB: CanvasNode,
{ sourceIndex = 0, targetIndex = 0 } = {}, { sourceIndex = 0, targetIndex = 0 } = {},
) { ) {
const nodeAOutput = nodeA.data?.outputs[sourceIndex]; const nodeAOutput = nodeA.data?.outputs[sourceIndex];

View file

@ -4,7 +4,7 @@ import { fireEvent, waitFor } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import Canvas from '@/components/canvas/Canvas.vue'; import Canvas from '@/components/canvas/Canvas.vue';
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import type { CanvasConnection, CanvasElement } from '@/types'; import type { CanvasConnection, CanvasNode } from '@/types';
import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/data'; import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/data';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
@ -44,7 +44,7 @@ describe('Canvas', () => {
}); });
it('should render nodes and edges', async () => { it('should render nodes and edges', async () => {
const elements: CanvasElement[] = [ const nodes: CanvasNode[] = [
createCanvasNodeElement({ createCanvasNodeElement({
id: '1', id: '1',
label: 'Node 1', label: 'Node 1',
@ -72,33 +72,33 @@ describe('Canvas', () => {
}), }),
]; ];
const connections: CanvasConnection[] = [createCanvasConnection(elements[0], elements[1])]; const connections: CanvasConnection[] = [createCanvasConnection(nodes[0], nodes[1])];
const { container } = renderComponent({ const { container } = renderComponent({
props: { props: {
elements, nodes,
connections, connections,
}, },
}); });
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(2)); await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(2));
expect(container.querySelector(`[data-id="${elements[0].id}"]`)).toBeInTheDocument(); expect(container.querySelector(`[data-id="${nodes[0].id}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-id="${elements[1].id}"]`)).toBeInTheDocument(); expect(container.querySelector(`[data-id="${nodes[1].id}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-id="${connections[0].id}"]`)).toBeInTheDocument(); expect(container.querySelector(`[data-id="${connections[0].id}"]`)).toBeInTheDocument();
}); });
it('should handle node drag stop event', async () => { it('should handle node drag stop event', async () => {
const elements = [createCanvasNodeElement()]; const nodes = [createCanvasNodeElement()];
const { container, emitted } = renderComponent({ const { container, emitted } = renderComponent({
props: { props: {
elements, nodes,
}, },
}); });
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1)); await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
const node = container.querySelector(`[data-id="${elements[0].id}"]`) as Element; const node = container.querySelector(`[data-id="${nodes[0].id}"]`) as Element;
await fireEvent.mouseDown(node, { view: window }); await fireEvent.mouseDown(node, { view: window });
await fireEvent.mouseMove(node, { await fireEvent.mouseMove(node, {
view: window, view: window,

View file

@ -1,18 +1,18 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { CanvasConnection, CanvasElement, ConnectStartEvent } from '@/types'; import type { CanvasConnection, CanvasNode, ConnectStartEvent } from '@/types';
import type { EdgeMouseEvent, NodeDragEvent, Connection, XYPosition } from '@vue-flow/core'; import type { EdgeMouseEvent, NodeDragEvent, Connection, XYPosition } from '@vue-flow/core';
import { useVueFlow, VueFlow, PanelPosition } from '@vue-flow/core'; import { useVueFlow, VueFlow, PanelPosition } from '@vue-flow/core';
import { Background } from '@vue-flow/background'; import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls'; import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap'; import { MiniMap } from '@vue-flow/minimap';
import CanvasNode from './elements/nodes/CanvasNode.vue'; import Node from './elements/nodes/CanvasNode.vue';
import CanvasEdge from './elements/edges/CanvasEdge.vue'; import Edge from './elements/edges/CanvasEdge.vue';
import { onMounted, onUnmounted, ref, useCssModule } from 'vue'; import { onMounted, onUnmounted, ref, useCssModule } from 'vue';
const $style = useCssModule(); const $style = useCssModule();
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [elements: CanvasElement[]]; 'update:modelValue': [elements: CanvasNode[]];
'update:node:position': [id: string, position: XYPosition]; 'update:node:position': [id: string, position: XYPosition];
'update:node:active': [id: string]; 'update:node:active': [id: string];
'update:node:enabled': [id: string]; 'update:node:enabled': [id: string];
@ -30,13 +30,13 @@ const emit = defineEmits<{
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
id?: string; id?: string;
elements: CanvasElement[]; nodes: CanvasNode[];
connections: CanvasConnection[]; connections: CanvasConnection[];
controlsPosition?: PanelPosition; controlsPosition?: PanelPosition;
}>(), }>(),
{ {
id: 'canvas', id: 'canvas',
elements: () => [], nodes: () => [],
connections: () => [], connections: () => [],
controlsPosition: PanelPosition.BottomLeft, controlsPosition: PanelPosition.BottomLeft,
}, },
@ -156,7 +156,7 @@ function onClickPane(event: MouseEvent) {
<template> <template>
<VueFlow <VueFlow
:id="id" :id="id"
:nodes="elements" :nodes="nodes"
:edges="connections" :edges="connections"
:apply-changes="false" :apply-changes="false"
fit-view-on-init fit-view-on-init
@ -176,7 +176,7 @@ function onClickPane(event: MouseEvent) {
@pane-click="onClickPane" @pane-click="onClickPane"
> >
<template #node-canvas-node="canvasNodeProps"> <template #node-canvas-node="canvasNodeProps">
<CanvasNode <Node
v-bind="canvasNodeProps" v-bind="canvasNodeProps"
@delete="onDeleteNode" @delete="onDeleteNode"
@run="onRunNode" @run="onRunNode"
@ -187,7 +187,7 @@ function onClickPane(event: MouseEvent) {
</template> </template>
<template #edge-canvas-edge="canvasEdgeProps"> <template #edge-canvas-edge="canvasEdgeProps">
<CanvasEdge <Edge
v-bind="canvasEdgeProps" v-bind="canvasEdgeProps"
:hovered="hoveredEdges[canvasEdgeProps.id]" :hovered="hoveredEdges[canvasEdgeProps.id]"
@delete="onDeleteConnection" @delete="onDeleteConnection"

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import Canvas from '@/components/canvas/Canvas.vue'; import Canvas from '@/components/canvas/Canvas.vue';
import { toRef, useCssModule } from 'vue'; import { computed, toRef, useCssModule } from 'vue';
import type { Workflow } from 'n8n-workflow'; import type { Workflow } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb } from '@/Interface';
import { useCanvasMapping } from '@/composables/useCanvasMapping'; import { useCanvasMapping } from '@/composables/useCanvasMapping';
@ -9,24 +9,45 @@ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); });
const props = defineProps<{ const props = withDefaults(
id?: string; defineProps<{
workflow: IWorkflowDb; id?: string;
workflowObject: Workflow; workflow: IWorkflowDb;
}>(); workflowObject: Workflow;
fallbackNodes?: IWorkflowDb['nodes'];
}>(),
{
id: 'canvas',
fallbackNodes: () => [],
},
);
const $style = useCssModule(); const $style = useCssModule();
const workflow = toRef(props, 'workflow'); const workflow = toRef(props, 'workflow');
const workflowObject = toRef(props, 'workflowObject'); const workflowObject = toRef(props, 'workflowObject');
const { elements, connections } = useCanvasMapping({ workflow, workflowObject }); const nodes = computed(() =>
props.workflow.nodes.length > 0 ? props.workflow.nodes : props.fallbackNodes,
);
const connections = computed(() => props.workflow.connections);
const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping({
nodes,
connections,
workflowObject,
});
</script> </script>
<template> <template>
<div :class="$style.wrapper"> <div :class="$style.wrapper">
<div :class="$style.canvas"> <div :class="$style.canvas">
<Canvas v-if="workflow" :elements="elements" :connections="connections" v-bind="$attrs" /> <Canvas
v-if="workflow"
:nodes="mappedNodes"
:connections="mappedConnections"
v-bind="$attrs"
/>
</div> </div>
<slot /> <slot />
</div> </div>

View file

@ -1,9 +1,9 @@
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import CanvasExecuteWorkflowButton from './CanvasExecuteWorkflowButton.vue'; import CanvasRunWorkflowButton from './CanvasRunWorkflowButton.vue';
const renderComponent = createComponentRenderer(CanvasExecuteWorkflowButton); const renderComponent = createComponentRenderer(CanvasRunWorkflowButton);
describe('CanvasExecuteWorkflowButton', () => { describe('CanvasRunWorkflowButton', () => {
it('should render correctly', () => { it('should render correctly', () => {
const wrapper = renderComponent(); const wrapper = renderComponent();

View file

@ -4,12 +4,15 @@ import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
defineEmits<{ defineEmits<{
mouseenter: [event: MouseEvent];
mouseleave: [event: MouseEvent];
click: [event: MouseEvent]; click: [event: MouseEvent];
}>(); }>();
const props = defineProps<{ const props = defineProps<{
waitingForWebhook: boolean; waitingForWebhook: boolean;
executing: boolean; executing: boolean;
disabled?: boolean;
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
@ -32,10 +35,13 @@ const label = computed(() => {
<N8nButton <N8nButton
:loading="executing" :loading="executing"
:label="label" :label="label"
:disabled="disabled"
size="large" size="large"
icon="flask" icon="flask"
type="primary" type="primary"
data-test-id="execute-workflow-button" data-test-id="execute-workflow-button"
@mouseenter="$emit('mouseenter', $event)"
@mouseleave="$emit('mouseleave', $event)"
@click.stop="$emit('click', $event)" @click.stop="$emit('click', $event)"
/> />
</KeyboardShortcutTooltip> </KeyboardShortcutTooltip>

View file

@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasRunWorkflowButton > should render correctly 1`] = `
"<button class="button button primary large withIcon el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="execute-workflow-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-flask fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="flask" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M437.2 403.5L320 215V64h8c13.3 0 24-10.7 24-24V24c0-13.3-10.7-24-24-24H120c-13.3 0-24 10.7-24 24v16c0 13.3 10.7 24 24 24h8v151L10.8 403.5C-18.5 450.6 15.3 512 70.9 512h306.2c55.7 0 89.4-61.5 60.1-108.5zM137.9 320l48.2-77.6c3.7-5.2 5.8-11.6 5.8-18.4V64h64v160c0 6.9 2.2 13.2 5.8 18.4l48.2 77.6h-172z"></path></svg></span></span><span>Test workflow</span></button>
<!--teleport start-->
<!--teleport end-->"
`;

View file

@ -1,11 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Position } from '@vue-flow/core'; import { Position } from '@vue-flow/core';
import { computed, provide, toRef, watch } from 'vue'; import { computed, provide, toRef, watch } from 'vue';
import type { import type { CanvasNodeData, CanvasConnectionPort, CanvasElementPortWithPosition } from '@/types';
CanvasElementData,
CanvasConnectionPort,
CanvasElementPortWithPosition,
} from '@/types';
import NodeIcon from '@/components/NodeIcon.vue'; import NodeIcon from '@/components/NodeIcon.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue'; import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
@ -22,7 +18,7 @@ const emit = defineEmits<{
toggle: [id: string]; toggle: [id: string];
activate: [id: string]; activate: [id: string];
}>(); }>();
const props = defineProps<NodeProps<CanvasElementData>>(); const props = defineProps<NodeProps<CanvasNodeData>>();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
@ -161,9 +157,15 @@ function onActivate() {
@run="onRun" @run="onRun"
/> />
<CanvasNodeRenderer v-if="nodeType" @dblclick="onActivate"> <CanvasNodeRenderer @dblclick="onActivate">
<NodeIcon :node-type="nodeType" :size="nodeIconSize" :shrink="false" :disabled="isDisabled" /> <NodeIcon
<!-- :color-default="iconColorDefault"--> v-if="nodeType"
:node-type="nodeType"
:size="nodeIconSize"
:shrink="false"
:disabled="isDisabled"
/>
<!-- @TODO :color-default="iconColorDefault"-->
</CanvasNodeRenderer> </CanvasNodeRenderer>
</div> </div>
</template> </template>

View file

@ -3,6 +3,7 @@ import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data'; import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { CanvasNodeRenderType } from '@/types';
const renderComponent = createComponentRenderer(CanvasNodeRenderer); const renderComponent = createComponentRenderer(CanvasNodeRenderer);
@ -31,7 +32,7 @@ describe('CanvasNodeRenderer', () => {
...createCanvasNodeProvide({ ...createCanvasNodeProvide({
data: { data: {
render: { render: {
type: 'default', type: CanvasNodeRenderType.Default,
options: { configuration: true }, options: { configuration: true },
}, },
}, },
@ -50,7 +51,7 @@ describe('CanvasNodeRenderer', () => {
...createCanvasNodeProvide({ ...createCanvasNodeProvide({
data: { data: {
render: { render: {
type: 'default', type: CanvasNodeRenderType.Default,
options: { configurable: true }, options: { configurable: true },
}, },
}, },

View file

@ -1,7 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { h, inject } from 'vue'; import { h, inject } from 'vue';
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue'; import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
import CanvasNodeAddNodes from '@/components/canvas/elements/nodes/render-types/CanvasNodeAddNodes.vue';
import { CanvasNodeKey } from '@/constants'; import { CanvasNodeKey } from '@/constants';
import { CanvasNodeRenderType } from '@/types';
const node = inject(CanvasNodeKey); const node = inject(CanvasNodeKey);
@ -13,6 +15,9 @@ const Render = () => {
let Component; let Component;
switch (node?.data.value.render.type) { switch (node?.data.value.render.type) {
// @TODO Add support for sticky notes here // @TODO Add support for sticky notes here
case CanvasNodeRenderType.AddNodes:
Component = CanvasNodeAddNodes;
break;
default: default:
Component = CanvasNodeDefault; Component = CanvasNodeDefault;
} }

View file

@ -2,6 +2,7 @@ import { fireEvent } from '@testing-library/vue';
import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue'; import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data'; import { createCanvasNodeProvide } from '@/__tests__/data';
import { CanvasNodeRenderType } from '@/types';
const renderComponent = createComponentRenderer(CanvasNodeToolbar); const renderComponent = createComponentRenderer(CanvasNodeToolbar);
@ -25,7 +26,7 @@ describe('CanvasNodeToolbar', () => {
...createCanvasNodeProvide({ ...createCanvasNodeProvide({
data: { data: {
render: { render: {
type: 'default', type: CanvasNodeRenderType.Default,
options: { configuration: true }, options: { configuration: true },
}, },
}, },

View file

@ -0,0 +1,96 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { NODE_CREATOR_OPEN_SOURCES } from '@/constants';
import { nodeViewEventBus } from '@/event-bus';
const nodeCreatorStore = useNodeCreatorStore();
const isTooltipVisible = ref(false);
onMounted(() => {
nodeViewEventBus.on('runWorkflowButton:mouseenter', onShowTooltip);
nodeViewEventBus.on('runWorkflowButton:mouseleave', onHideTooltip);
});
onBeforeUnmount(() => {
nodeViewEventBus.off('runWorkflowButton:mouseenter', onShowTooltip);
nodeViewEventBus.off('runWorkflowButton:mouseleave', onHideTooltip);
});
function onShowTooltip() {
isTooltipVisible.value = true;
}
function onHideTooltip() {
isTooltipVisible.value = false;
}
function onClick() {
nodeCreatorStore.openNodeCreatorForTriggerNodes(
NODE_CREATOR_OPEN_SOURCES.TRIGGER_PLACEHOLDER_BUTTON,
);
}
</script>
<template>
<div ref="container" :class="$style.addNodes" data-test-id="canvas-add-button">
<N8nTooltip
placement="top"
:visible="isTooltipVisible"
:disabled="nodeCreatorStore.showScrim"
:popper-class="$style.tooltip"
:show-after="700"
>
<button :class="$style.button" data-test-id="canvas-plus-button" @click="onClick">
<FontAwesomeIcon icon="plus" size="lg" />
</button>
<template #content>
{{ $locale.baseText('nodeView.canvasAddButton.addATriggerNodeBeforeExecuting') }}
</template>
</N8nTooltip>
<p :class="$style.label" v-text="$locale.baseText('nodeView.canvasAddButton.addFirstStep')" />
</div>
</template>
<style lang="scss" module>
.addNodes {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
&:hover .button svg path {
fill: var(--color-primary);
}
}
.button {
background: var(--color-foreground-xlight);
border: 2px dashed var(--color-foreground-xdark);
border-radius: 8px;
padding: 0;
min-width: 100px;
min-height: 100px;
cursor: pointer;
svg {
width: 26px !important;
height: 40px;
path {
fill: var(--color-foreground-xdark);
}
}
}
.label {
width: max-content;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-m);
line-height: var(--font-line-height-xloose);
color: var(--color-text-dark);
margin-top: var(--spacing-2xs);
}
</style>

View file

@ -4,6 +4,7 @@ import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data'; import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { CanvasNodeRenderType } from '@/types';
const renderComponent = createComponentRenderer(CanvasNodeDefault); const renderComponent = createComponentRenderer(CanvasNodeDefault);
@ -140,7 +141,7 @@ describe('CanvasNodeDefault', () => {
...createCanvasNodeProvide({ ...createCanvasNodeProvide({
data: { data: {
render: { render: {
type: 'default', type: CanvasNodeRenderType.Default,
options: { configurable: true }, options: { configurable: true },
}, },
}, },
@ -166,7 +167,7 @@ describe('CanvasNodeDefault', () => {
{ type: NodeConnectionType.AiMemory, index: 0, required: true }, { type: NodeConnectionType.AiMemory, index: 0, required: true },
], ],
render: { render: {
type: 'default', type: CanvasNodeRenderType.Default,
options: { options: {
configurable: true, configurable: true,
}, },
@ -191,7 +192,7 @@ describe('CanvasNodeDefault', () => {
...createCanvasNodeProvide({ ...createCanvasNodeProvide({
data: { data: {
render: { render: {
type: 'default', type: CanvasNodeRenderType.Default,
options: { configuration: true }, options: { configuration: true },
}, },
}, },
@ -210,7 +211,7 @@ describe('CanvasNodeDefault', () => {
...createCanvasNodeProvide({ ...createCanvasNodeProvide({
data: { data: {
render: { render: {
type: 'default', type: CanvasNodeRenderType.Default,
options: { configurable: true, configuration: true }, options: { configurable: true, configuration: true },
}, },
}, },
@ -231,7 +232,7 @@ describe('CanvasNodeDefault', () => {
...createCanvasNodeProvide({ ...createCanvasNodeProvide({
data: { data: {
render: { render: {
type: 'default', type: CanvasNodeRenderType.Default,
options: { trigger: true }, options: { trigger: true },
}, },
}, },

View file

@ -3,7 +3,7 @@ import type { Connection } from '@vue-flow/core';
import type { IConnection, Workflow } from 'n8n-workflow'; import type { IConnection, Workflow } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import { useCanvasOperations } from '@/composables/useCanvasOperations'; import { useCanvasOperations } from '@/composables/useCanvasOperations';
import type { CanvasElement } from '@/types'; import type { CanvasNode } from '@/types';
import type { ICredentialsResponse, INodeUi, IWorkflowDb, XYPosition } from '@/Interface'; import type { ICredentialsResponse, INodeUi, IWorkflowDb, XYPosition } from '@/Interface';
import { RemoveNodeCommand } from '@/models/history'; import { RemoveNodeCommand } from '@/models/history';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
@ -76,7 +76,7 @@ describe('useCanvasOperations', () => {
.spyOn(workflowsStore, 'setNodePositionById') .spyOn(workflowsStore, 'setNodePositionById')
.mockImplementation(() => {}); .mockImplementation(() => {});
const id = 'node1'; const id = 'node1';
const position: CanvasElement['position'] = { x: 10, y: 20 }; const position: CanvasNode['position'] = { x: 10, y: 20 };
const node = createTestNode({ const node = createTestNode({
id, id,
type: 'node', type: 'node',

View file

@ -1,19 +1,19 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import { useNodeConnections } from '@/composables/useNodeConnections'; import { useNodeConnections } from '@/composables/useNodeConnections';
import type { CanvasElementData } from '@/types'; import type { CanvasNodeData } from '@/types';
describe('useNodeConnections', () => { describe('useNodeConnections', () => {
const defaultConnections = { input: {}, output: {} }; const defaultConnections = { input: {}, output: {} };
describe('mainInputs', () => { describe('mainInputs', () => {
it('should return main inputs when provided with main inputs', () => { it('should return main inputs when provided with main inputs', () => {
const inputs = ref<CanvasElementData['inputs']>([ const inputs = ref<CanvasNodeData['inputs']>([
{ type: NodeConnectionType.Main, index: 0 }, { type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 1 }, { type: NodeConnectionType.Main, index: 1 },
{ type: NodeConnectionType.Main, index: 2 }, { type: NodeConnectionType.Main, index: 2 },
{ type: NodeConnectionType.AiAgent, index: 0 }, { type: NodeConnectionType.AiAgent, index: 0 },
]); ]);
const outputs = ref<CanvasElementData['outputs']>([]); const outputs = ref<CanvasNodeData['outputs']>([]);
const { mainInputs } = useNodeConnections({ const { mainInputs } = useNodeConnections({
inputs, inputs,
@ -28,12 +28,12 @@ describe('useNodeConnections', () => {
describe('nonMainInputs', () => { describe('nonMainInputs', () => {
it('should return non-main inputs when provided with non-main inputs', () => { it('should return non-main inputs when provided with non-main inputs', () => {
const inputs = ref<CanvasElementData['inputs']>([ const inputs = ref<CanvasNodeData['inputs']>([
{ type: NodeConnectionType.Main, index: 0 }, { type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.AiAgent, index: 0 }, { type: NodeConnectionType.AiAgent, index: 0 },
{ type: NodeConnectionType.AiAgent, index: 1 }, { type: NodeConnectionType.AiAgent, index: 1 },
]); ]);
const outputs = ref<CanvasElementData['outputs']>([]); const outputs = ref<CanvasNodeData['outputs']>([]);
const { nonMainInputs } = useNodeConnections({ const { nonMainInputs } = useNodeConnections({
inputs, inputs,
@ -48,12 +48,12 @@ describe('useNodeConnections', () => {
describe('requiredNonMainInputs', () => { describe('requiredNonMainInputs', () => {
it('should return required non-main inputs when provided with required non-main inputs', () => { it('should return required non-main inputs when provided with required non-main inputs', () => {
const inputs = ref<CanvasElementData['inputs']>([ const inputs = ref<CanvasNodeData['inputs']>([
{ type: NodeConnectionType.Main, index: 0 }, { type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.AiAgent, required: true, index: 0 }, { type: NodeConnectionType.AiAgent, required: true, index: 0 },
{ type: NodeConnectionType.AiAgent, required: false, index: 1 }, { type: NodeConnectionType.AiAgent, required: false, index: 1 },
]); ]);
const outputs = ref<CanvasElementData['outputs']>([]); const outputs = ref<CanvasNodeData['outputs']>([]);
const { requiredNonMainInputs } = useNodeConnections({ const { requiredNonMainInputs } = useNodeConnections({
inputs, inputs,
@ -68,9 +68,9 @@ describe('useNodeConnections', () => {
describe('mainInputConnections', () => { describe('mainInputConnections', () => {
it('should return main input connections when provided with main input connections', () => { it('should return main input connections when provided with main input connections', () => {
const inputs = ref<CanvasElementData['inputs']>([]); const inputs = ref<CanvasNodeData['inputs']>([]);
const outputs = ref<CanvasElementData['outputs']>([]); const outputs = ref<CanvasNodeData['outputs']>([]);
const connections = ref<CanvasElementData['connections']>({ const connections = ref<CanvasNodeData['connections']>({
input: { input: {
[NodeConnectionType.Main]: [ [NodeConnectionType.Main]: [
[{ node: 'node1', type: NodeConnectionType.Main, index: 0 }], [{ node: 'node1', type: NodeConnectionType.Main, index: 0 }],
@ -93,8 +93,8 @@ describe('useNodeConnections', () => {
describe('mainOutputs', () => { describe('mainOutputs', () => {
it('should return main outputs when provided with main outputs', () => { it('should return main outputs when provided with main outputs', () => {
const inputs = ref<CanvasElementData['inputs']>([]); const inputs = ref<CanvasNodeData['inputs']>([]);
const outputs = ref<CanvasElementData['outputs']>([ const outputs = ref<CanvasNodeData['outputs']>([
{ type: NodeConnectionType.Main, index: 0 }, { type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 1 }, { type: NodeConnectionType.Main, index: 1 },
{ type: NodeConnectionType.Main, index: 2 }, { type: NodeConnectionType.Main, index: 2 },
@ -114,8 +114,8 @@ describe('useNodeConnections', () => {
describe('nonMainOutputs', () => { describe('nonMainOutputs', () => {
it('should return non-main outputs when provided with non-main outputs', () => { it('should return non-main outputs when provided with non-main outputs', () => {
const inputs = ref<CanvasElementData['inputs']>([]); const inputs = ref<CanvasNodeData['inputs']>([]);
const outputs = ref<CanvasElementData['outputs']>([ const outputs = ref<CanvasNodeData['outputs']>([
{ type: NodeConnectionType.Main, index: 0 }, { type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.AiAgent, index: 0 }, { type: NodeConnectionType.AiAgent, index: 0 },
{ type: NodeConnectionType.AiAgent, index: 1 }, { type: NodeConnectionType.AiAgent, index: 1 },
@ -134,9 +134,9 @@ describe('useNodeConnections', () => {
describe('mainOutputConnections', () => { describe('mainOutputConnections', () => {
it('should return main output connections when provided with main output connections', () => { it('should return main output connections when provided with main output connections', () => {
const inputs = ref<CanvasElementData['inputs']>([]); const inputs = ref<CanvasNodeData['inputs']>([]);
const outputs = ref<CanvasElementData['outputs']>([]); const outputs = ref<CanvasNodeData['outputs']>([]);
const connections = ref<CanvasElementData['connections']>({ const connections = ref<CanvasNodeData['connections']>({
input: {}, input: {},
output: { output: {
[NodeConnectionType.Main]: [ [NodeConnectionType.Main]: [

View file

@ -3,10 +3,9 @@ import { ref } from 'vue';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import type { Workflow } from 'n8n-workflow'; import type { Workflow } from 'n8n-workflow';
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { mock } from 'vitest-mock-extended';
import { useCanvasMapping } from '@/composables/useCanvasMapping'; import { useCanvasMapping } from '@/composables/useCanvasMapping';
import type { IWorkflowDb } from '@/Interface'; import type { INodeUi } from '@/Interface';
import { import {
createTestWorkflowObject, createTestWorkflowObject,
mockNode, mockNode,
@ -15,7 +14,7 @@ import {
} from '@/__tests__/mocks'; } from '@/__tests__/mocks';
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants'; import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '../stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
beforeEach(() => { beforeEach(() => {
const pinia = createPinia(); const pinia = createPinia();
@ -37,38 +36,44 @@ afterEach(() => {
describe('useCanvasMapping', () => { describe('useCanvasMapping', () => {
it('should initialize with default props', () => { it('should initialize with default props', () => {
const workflow = mock<IWorkflowDb>({ const nodes: INodeUi[] = [];
nodes: [], const connections = {};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
}); });
const workflowObject = createTestWorkflowObject(workflow);
const { elements, connections } = useCanvasMapping({ const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping({
workflow: ref(workflow), nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>, workflowObject: ref(workflowObject) as Ref<Workflow>,
}); });
expect(elements.value).toEqual([]); expect(mappedNodes.value).toEqual([]);
expect(connections.value).toEqual([]); expect(mappedConnections.value).toEqual([]);
}); });
describe('elements', () => { describe('nodes', () => {
it('should map nodes to canvas elements', () => { it('should map nodes to canvas nodes', () => {
const manualTriggerNode = mockNode({ const manualTriggerNode = mockNode({
name: 'Manual Trigger', name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE, type: MANUAL_TRIGGER_NODE_TYPE,
disabled: false, disabled: false,
}); });
const workflow = mock<IWorkflowDb>({ const nodes = [manualTriggerNode];
nodes: [manualTriggerNode], const connections = {};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
}); });
const workflowObject = createTestWorkflowObject(workflow);
const { elements } = useCanvasMapping({ const { nodes: mappedNodes } = useCanvasMapping({
workflow: ref(workflow), nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>, workflowObject: ref(workflowObject) as Ref<Workflow>,
}); });
expect(elements.value).toEqual([ expect(mappedNodes.value).toEqual([
{ {
id: manualTriggerNode.id, id: manualTriggerNode.id,
label: manualTriggerNode.name, label: manualTriggerNode.name,
@ -133,17 +138,20 @@ describe('useCanvasMapping', () => {
type: MANUAL_TRIGGER_NODE_TYPE, type: MANUAL_TRIGGER_NODE_TYPE,
disabled: true, disabled: true,
}); });
const workflow = mock<IWorkflowDb>({ const nodes = [manualTriggerNode];
nodes: [manualTriggerNode], const connections = {};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
}); });
const workflowObject = createTestWorkflowObject(workflow);
const { elements } = useCanvasMapping({ const { nodes: mappedNodes } = useCanvasMapping({
workflow: ref(workflow), nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>, workflowObject: ref(workflowObject) as Ref<Workflow>,
}); });
expect(elements.value[0]?.data?.disabled).toEqual(true); expect(mappedNodes.value[0]?.data?.disabled).toEqual(true);
}); });
it('should handle execution state', () => { it('should handle execution state', () => {
@ -152,42 +160,49 @@ describe('useCanvasMapping', () => {
type: MANUAL_TRIGGER_NODE_TYPE, type: MANUAL_TRIGGER_NODE_TYPE,
disabled: true, disabled: true,
}); });
const workflow = mock<IWorkflowDb>({ const nodes = [manualTriggerNode];
nodes: [manualTriggerNode], const connections = {};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
}); });
const workflowObject = createTestWorkflowObject(workflow);
useWorkflowsStore().addExecutingNode(manualTriggerNode.name); useWorkflowsStore().addExecutingNode(manualTriggerNode.name);
const { elements } = useCanvasMapping({ const { nodes: mappedNodes } = useCanvasMapping({
workflow: ref(workflow), nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>, workflowObject: ref(workflowObject) as Ref<Workflow>,
}); });
expect(elements.value[0]?.data?.execution.running).toEqual(true); expect(mappedNodes.value[0]?.data?.execution.running).toEqual(true);
}); });
it('should handle input and output connections', () => { it('should handle input and output connections', () => {
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
const workflow = mock<IWorkflowDb>({ const nodes = [manualTriggerNode, setNode];
nodes: [manualTriggerNode, setNode], const connections = {
connections: { [manualTriggerNode.name]: {
[manualTriggerNode.name]: { [NodeConnectionType.Main]: [
[NodeConnectionType.Main]: [ [{ node: setNode.name, type: NodeConnectionType.Main, index: 0 }],
[{ node: setNode.name, type: NodeConnectionType.Main, index: 0 }], ],
],
},
}, },
};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
}); });
const workflowObject = createTestWorkflowObject(workflow);
const { elements } = useCanvasMapping({ const { nodes: mappedNodes } = useCanvasMapping({
workflow: ref(workflow), nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>, workflowObject: ref(workflowObject) as Ref<Workflow>,
}); });
expect(elements.value[0]?.data?.connections.output).toHaveProperty(NodeConnectionType.Main); expect(mappedNodes.value[0]?.data?.connections.output).toHaveProperty(
expect(elements.value[0]?.data?.connections.output[NodeConnectionType.Main][0][0]).toEqual( NodeConnectionType.Main,
);
expect(mappedNodes.value[0]?.data?.connections.output[NodeConnectionType.Main][0][0]).toEqual(
expect.objectContaining({ expect.objectContaining({
node: setNode.name, node: setNode.name,
type: NodeConnectionType.Main, type: NodeConnectionType.Main,
@ -195,8 +210,8 @@ describe('useCanvasMapping', () => {
}), }),
); );
expect(elements.value[1]?.data?.connections.input).toHaveProperty(NodeConnectionType.Main); expect(mappedNodes.value[1]?.data?.connections.input).toHaveProperty(NodeConnectionType.Main);
expect(elements.value[1]?.data?.connections.input[NodeConnectionType.Main][0][0]).toEqual( expect(mappedNodes.value[1]?.data?.connections.input[NodeConnectionType.Main][0][0]).toEqual(
expect.objectContaining({ expect.objectContaining({
node: manualTriggerNode.name, node: manualTriggerNode.name,
type: NodeConnectionType.Main, type: NodeConnectionType.Main,
@ -209,24 +224,26 @@ describe('useCanvasMapping', () => {
describe('connections', () => { describe('connections', () => {
it('should map connections to canvas connections', () => { it('should map connections to canvas connections', () => {
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
const workflow = mock<IWorkflowDb>({ const nodes = [manualTriggerNode, setNode];
nodes: [manualTriggerNode, setNode], const connections = {
connections: { [manualTriggerNode.name]: {
[manualTriggerNode.name]: { [NodeConnectionType.Main]: [
[NodeConnectionType.Main]: [ [{ node: setNode.name, type: NodeConnectionType.Main, index: 0 }],
[{ node: setNode.name, type: NodeConnectionType.Main, index: 0 }], ],
],
},
}, },
};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
}); });
const workflowObject = createTestWorkflowObject(workflow);
const { connections } = useCanvasMapping({ const { connections: mappedConnections } = useCanvasMapping({
workflow: ref(workflow), nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>, workflowObject: ref(workflowObject) as Ref<Workflow>,
}); });
expect(connections.value).toEqual([ expect(mappedConnections.value).toEqual([
{ {
data: { data: {
fromNodeName: manualTriggerNode.name, fromNodeName: manualTriggerNode.name,
@ -254,27 +271,29 @@ describe('useCanvasMapping', () => {
it('should map multiple input types to canvas connections', () => { it('should map multiple input types to canvas connections', () => {
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
const workflow = mock<IWorkflowDb>({ const nodes = [manualTriggerNode, setNode];
nodes: [manualTriggerNode, setNode], const connections = {
connections: { [manualTriggerNode.name]: {
[manualTriggerNode.name]: { [NodeConnectionType.AiTool]: [
[NodeConnectionType.AiTool]: [ [{ node: setNode.name, type: NodeConnectionType.AiTool, index: 0 }],
[{ node: setNode.name, type: NodeConnectionType.AiTool, index: 0 }], ],
], [NodeConnectionType.AiDocument]: [
[NodeConnectionType.AiDocument]: [ [{ node: setNode.name, type: NodeConnectionType.AiDocument, index: 1 }],
[{ node: setNode.name, type: NodeConnectionType.AiDocument, index: 1 }], ],
],
},
}, },
};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
}); });
const workflowObject = createTestWorkflowObject(workflow);
const { connections } = useCanvasMapping({ const { connections: mappedConnections } = useCanvasMapping({
workflow: ref(workflow), nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>, workflowObject: ref(workflowObject) as Ref<Workflow>,
}); });
expect(connections.value).toEqual([ expect(mappedConnections.value).toEqual([
{ {
data: { data: {
fromNodeName: manualTriggerNode.name, fromNodeName: manualTriggerNode.name,

View file

@ -12,9 +12,10 @@ import type {
CanvasConnection, CanvasConnection,
CanvasConnectionData, CanvasConnectionData,
CanvasConnectionPort, CanvasConnectionPort,
CanvasElement, CanvasNode,
CanvasElementData, CanvasNodeData,
} from '@/types'; } from '@/types';
import { CanvasNodeRenderType } from '@/types';
import { import {
mapLegacyConnectionsToCanvasConnections, mapLegacyConnectionsToCanvasConnections,
mapLegacyEndpointsToCanvasConnectionPort, mapLegacyEndpointsToCanvasConnectionPort,
@ -22,20 +23,23 @@ import {
import type { import type {
ExecutionStatus, ExecutionStatus,
ExecutionSummary, ExecutionSummary,
IConnections,
INodeExecutionData, INodeExecutionData,
ITaskData, ITaskData,
Workflow, Workflow,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow'; import { NodeHelpers } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interface'; import type { INodeUi } from '@/Interface';
import { WAIT_TIME_UNLIMITED } from '@/constants'; import { WAIT_TIME_UNLIMITED } from '@/constants';
import { sanitizeHtml } from '@/utils/htmlUtils'; import { sanitizeHtml } from '@/utils/htmlUtils';
export function useCanvasMapping({ export function useCanvasMapping({
workflow, nodes,
connections,
workflowObject, workflowObject,
}: { }: {
workflow: Ref<IWorkflowDb>; nodes: Ref<INodeUi[]>;
connections: Ref<IConnections>;
workflowObject: Ref<Workflow>; workflowObject: Ref<Workflow>;
}) { }) {
const i18n = useI18n(); const i18n = useI18n();
@ -44,24 +48,36 @@ export function useCanvasMapping({
const renderTypeByNodeType = computed( const renderTypeByNodeType = computed(
() => () =>
workflow.value.nodes.reduce<Record<string, CanvasElementData['render']>>((acc, node) => { nodes.value.reduce<Record<string, CanvasNodeData['render']>>((acc, node) => {
// @TODO Add support for sticky notes here // @TODO Add support for sticky notes here
switch (node.type) {
acc[node.type] = { case `${CanvasNodeRenderType.AddNodes}`:
type: 'default', acc[node.type] = {
options: { type: CanvasNodeRenderType.AddNodes,
trigger: nodeTypesStore.isTriggerNode(node.type), options: {},
configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type), };
configurable: nodeTypesStore.isConfigurableNode(workflowObject.value, node, node.type), break;
}, default:
}; acc[node.type] = {
type: CanvasNodeRenderType.Default,
options: {
trigger: nodeTypesStore.isTriggerNode(node.type),
configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type),
configurable: nodeTypesStore.isConfigurableNode(
workflowObject.value,
node,
node.type,
),
},
};
}
return acc; return acc;
}, {}) ?? {}, }, {}) ?? {},
); );
const nodeInputsById = computed(() => const nodeInputsById = computed(() =>
workflow.value.nodes.reduce<Record<string, CanvasConnectionPort[]>>((acc, node) => { nodes.value.reduce<Record<string, CanvasConnectionPort[]>>((acc, node) => {
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type); const nodeTypeDescription = nodeTypesStore.getNodeType(node.type);
const workflowObjectNode = workflowObject.value.getNode(node.name); const workflowObjectNode = workflowObject.value.getNode(node.name);
@ -81,7 +97,7 @@ export function useCanvasMapping({
); );
const nodeOutputsById = computed(() => const nodeOutputsById = computed(() =>
workflow.value.nodes.reduce<Record<string, CanvasConnectionPort[]>>((acc, node) => { nodes.value.reduce<Record<string, CanvasConnectionPort[]>>((acc, node) => {
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type); const nodeTypeDescription = nodeTypesStore.getNodeType(node.type);
const workflowObjectNode = workflowObject.value.getNode(node.name); const workflowObjectNode = workflowObject.value.getNode(node.name);
@ -101,21 +117,21 @@ export function useCanvasMapping({
); );
const nodePinnedDataById = computed(() => const nodePinnedDataById = computed(() =>
workflow.value.nodes.reduce<Record<string, INodeExecutionData[] | undefined>>((acc, node) => { nodes.value.reduce<Record<string, INodeExecutionData[] | undefined>>((acc, node) => {
acc[node.id] = workflowsStore.pinDataByNodeName(node.name); acc[node.id] = workflowsStore.pinDataByNodeName(node.name);
return acc; return acc;
}, {}), }, {}),
); );
const nodeExecutionRunningById = computed(() => const nodeExecutionRunningById = computed(() =>
workflow.value.nodes.reduce<Record<string, boolean>>((acc, node) => { nodes.value.reduce<Record<string, boolean>>((acc, node) => {
acc[node.id] = workflowsStore.isNodeExecuting(node.name); acc[node.id] = workflowsStore.isNodeExecuting(node.name);
return acc; return acc;
}, {}), }, {}),
); );
const nodeExecutionStatusById = computed(() => const nodeExecutionStatusById = computed(() =>
workflow.value.nodes.reduce<Record<string, ExecutionStatus>>((acc, node) => { nodes.value.reduce<Record<string, ExecutionStatus>>((acc, node) => {
acc[node.id] = acc[node.id] =
workflowsStore.getWorkflowRunData?.[node.name]?.filter(Boolean)[0].executionStatus ?? 'new'; workflowsStore.getWorkflowRunData?.[node.name]?.filter(Boolean)[0].executionStatus ?? 'new';
return acc; return acc;
@ -123,14 +139,14 @@ export function useCanvasMapping({
); );
const nodeExecutionRunDataById = computed(() => const nodeExecutionRunDataById = computed(() =>
workflow.value.nodes.reduce<Record<string, ITaskData[] | null>>((acc, node) => { nodes.value.reduce<Record<string, ITaskData[] | null>>((acc, node) => {
acc[node.id] = workflowsStore.getWorkflowResultDataByNodeName(node.name); acc[node.id] = workflowsStore.getWorkflowResultDataByNodeName(node.name);
return acc; return acc;
}, {}), }, {}),
); );
const nodeIssuesById = computed(() => const nodeIssuesById = computed(() =>
workflow.value.nodes.reduce<Record<string, string[]>>((acc, node) => { nodes.value.reduce<Record<string, string[]>>((acc, node) => {
const issues: string[] = []; const issues: string[] = [];
const nodeExecutionRunData = workflowsStore.getWorkflowRunData?.[node.name]; const nodeExecutionRunData = workflowsStore.getWorkflowRunData?.[node.name];
if (nodeExecutionRunData) { if (nodeExecutionRunData) {
@ -154,7 +170,7 @@ export function useCanvasMapping({
); );
const nodeHasIssuesById = computed(() => const nodeHasIssuesById = computed(() =>
workflow.value.nodes.reduce<Record<string, boolean>>((acc, node) => { nodes.value.reduce<Record<string, boolean>>((acc, node) => {
if (['crashed', 'error'].includes(nodeExecutionStatusById.value[node.id])) { if (['crashed', 'error'].includes(nodeExecutionStatusById.value[node.id])) {
acc[node.id] = true; acc[node.id] = true;
} else if (nodePinnedDataById.value[node.id]) { } else if (nodePinnedDataById.value[node.id]) {
@ -168,7 +184,7 @@ export function useCanvasMapping({
); );
const nodeExecutionWaitingById = computed(() => const nodeExecutionWaitingById = computed(() =>
workflow.value.nodes.reduce<Record<string, string | undefined>>((acc, node) => { nodes.value.reduce<Record<string, string | undefined>>((acc, node) => {
const isExecutionSummary = (execution: object): execution is ExecutionSummary => const isExecutionSummary = (execution: object): execution is ExecutionSummary =>
'waitTill' in execution; 'waitTill' in execution;
@ -198,12 +214,12 @@ export function useCanvasMapping({
}, {}), }, {}),
); );
const elements = computed<CanvasElement[]>(() => [ const mappedNodes = computed<CanvasNode[]>(() => [
...workflow.value.nodes.map<CanvasElement>((node) => { ...nodes.value.map<CanvasNode>((node) => {
const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {}; const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {};
const outputConnections = workflowObject.value.connectionsBySourceNode[node.name] ?? {}; const outputConnections = workflowObject.value.connectionsBySourceNode[node.name] ?? {};
const data: CanvasElementData = { const data: CanvasNodeData = {
id: node.id, id: node.id,
type: node.type, type: node.type,
typeVersion: node.typeVersion, typeVersion: node.typeVersion,
@ -244,31 +260,26 @@ export function useCanvasMapping({
}), }),
]); ]);
const connections = computed<CanvasConnection[]>(() => { const mappedConnections = computed<CanvasConnection[]>(() => {
const mappedConnections = mapLegacyConnectionsToCanvasConnections( return mapLegacyConnectionsToCanvasConnections(connections.value ?? [], nodes.value ?? []).map(
workflow.value.connections ?? [], (connection) => {
workflow.value.nodes ?? [], const type = getConnectionType(connection);
const label = getConnectionLabel(connection);
const data = getConnectionData(connection);
return {
...connection,
data,
type,
label,
animated: data.status === 'running',
};
},
); );
return mappedConnections.map((connection) => {
const type = getConnectionType(connection);
const label = getConnectionLabel(connection);
const data = getConnectionData(connection);
return {
...connection,
data,
type,
label,
animated: data.status === 'running',
};
});
}); });
function getConnectionData(connection: CanvasConnection): CanvasConnectionData { function getConnectionData(connection: CanvasConnection): CanvasConnectionData {
const fromNode = workflow.value.nodes.find( const fromNode = nodes.value.find((node) => node.name === connection.data?.fromNodeName);
(node) => node.name === connection.data?.fromNodeName,
);
let status: CanvasConnectionData['status']; let status: CanvasConnectionData['status'];
if (fromNode) { if (fromNode) {
@ -297,9 +308,7 @@ export function useCanvasMapping({
} }
function getConnectionLabel(connection: CanvasConnection): string { function getConnectionLabel(connection: CanvasConnection): string {
const fromNode = workflow.value.nodes.find( const fromNode = nodes.value.find((node) => node.name === connection.data?.fromNodeName);
(node) => node.name === connection.data?.fromNodeName,
);
if (!fromNode) { if (!fromNode) {
return ''; return '';
@ -323,7 +332,7 @@ export function useCanvasMapping({
} }
return { return {
connections, connections: mappedConnections,
elements, nodes: mappedNodes,
}; };
} }

View file

@ -1,6 +1,7 @@
import { useCanvasNode } from '@/composables/useCanvasNode'; import { useCanvasNode } from '@/composables/useCanvasNode';
import { inject, ref } from 'vue'; import { inject, ref } from 'vue';
import type { CanvasNodeInjectionData } from '../types'; import type { CanvasNodeInjectionData } from '../types';
import { CanvasNodeRenderType } from '../types';
vi.mock('vue', async () => { vi.mock('vue', async () => {
const actual = await vi.importActual('vue'); const actual = await vi.importActual('vue');
@ -47,7 +48,7 @@ describe('useCanvasNode', () => {
runData: { count: 1, visible: true }, runData: { count: 1, visible: true },
pinnedData: { count: 1, visible: true }, pinnedData: { count: 1, visible: true },
render: { render: {
type: 'default', type: CanvasNodeRenderType.Default,
options: { options: {
configurable: false, configurable: false,
configuration: false, configuration: false,

View file

@ -5,11 +5,12 @@
import { CanvasNodeKey } from '@/constants'; import { CanvasNodeKey } from '@/constants';
import { computed, inject } from 'vue'; import { computed, inject } from 'vue';
import type { CanvasElementData } from '@/types'; import type { CanvasNodeData } from '@/types';
import { CanvasNodeRenderType } from '@/types';
export function useCanvasNode() { export function useCanvasNode() {
const node = inject(CanvasNodeKey); const node = inject(CanvasNodeKey);
const data = computed<CanvasElementData>( const data = computed<CanvasNodeData>(
() => () =>
node?.data.value ?? { node?.data.value ?? {
id: '', id: '',
@ -26,7 +27,7 @@ export function useCanvasNode() {
}, },
runData: { count: 0, visible: false }, runData: { count: 0, visible: false },
render: { render: {
type: 'default', type: CanvasNodeRenderType.Default,
options: {}, options: {},
}, },
}, },

View file

@ -3,7 +3,7 @@
* @TODO Remove this notice when Canvas V2 is the only one in use * @TODO Remove this notice when Canvas V2 is the only one in use
*/ */
import type { CanvasElement } from '@/types'; import type { CanvasNode } from '@/types';
import { CanvasConnectionMode } from '@/types'; import { CanvasConnectionMode } from '@/types';
import type { import type {
AddedNodesAndConnections, AddedNodesAndConnections,
@ -105,7 +105,7 @@ export function useCanvasOperations({
function updateNodePosition( function updateNodePosition(
id: string, id: string,
position: CanvasElement['position'], position: CanvasNode['position'],
{ trackHistory = false, trackBulk = true } = {}, { trackHistory = false, trackBulk = true } = {},
) { ) {
const node = workflowsStore.getNodeById(id); const node = workflowsStore.getNodeById(id);

View file

@ -1,4 +1,4 @@
import type { CanvasElementData } from '@/types'; import type { CanvasNodeData } from '@/types';
import type { MaybeRef } from 'vue'; import type { MaybeRef } from 'vue';
import { computed, unref } from 'vue'; import { computed, unref } from 'vue';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
@ -8,9 +8,9 @@ export function useNodeConnections({
outputs, outputs,
connections, connections,
}: { }: {
inputs: MaybeRef<CanvasElementData['inputs']>; inputs: MaybeRef<CanvasNodeData['inputs']>;
outputs: MaybeRef<CanvasElementData['outputs']>; outputs: MaybeRef<CanvasNodeData['outputs']>;
connections: MaybeRef<CanvasElementData['connections']>; connections: MaybeRef<CanvasNodeData['connections']>;
}) { }) {
/** /**
* Inputs * Inputs

View file

@ -211,6 +211,17 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
} }
} }
function openNodeCreatorForTriggerNodes(source: NodeCreatorOpenSource) {
ndvStore.activeNodeName = null;
setSelectedView(TRIGGER_NODE_CREATOR_VIEW);
setShowScrim(true);
openNodeCreator({
source,
createNodeActive: true,
nodeCreatorView: TRIGGER_NODE_CREATOR_VIEW,
});
}
function getNodeCreatorFilter(nodeName: string, outputType?: NodeConnectionType) { function getNodeCreatorFilter(nodeName: string, outputType?: NodeConnectionType) {
let filter; let filter;
const workflow = workflowsStore.getCurrentWorkflow(); const workflow = workflowsStore.getCurrentWorkflow();
@ -252,6 +263,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
openNodeCreator, openNodeCreator,
openSelectiveNodeCreator, openSelectiveNodeCreator,
openNodeCreatorForConnectingNode, openNodeCreatorForConnectingNode,
openNodeCreatorForTriggerNodes,
allNodeCreatorNodes, allNodeCreatorNodes,
}; };
}); });

View file

@ -10,8 +10,6 @@ import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core';
import type { INodeUi } from '@/Interface'; import type { INodeUi } from '@/Interface';
import type { ComputedRef, Ref } from 'vue'; import type { ComputedRef, Ref } from 'vue';
export type CanvasElementType = 'node' | 'note';
export type CanvasConnectionPortType = ConnectionTypes; export type CanvasConnectionPortType = ConnectionTypes;
export const enum CanvasConnectionMode { export const enum CanvasConnectionMode {
@ -36,7 +34,26 @@ export interface CanvasElementPortWithPosition extends CanvasConnectionPort {
offset?: { top?: string; left?: string }; offset?: { top?: string; left?: string };
} }
export interface CanvasElementData { export const enum CanvasNodeRenderType {
Default = 'default',
AddNodes = 'n8n-nodes-internal.addNodes',
}
export type CanvasNodeDefaultRender = {
type: CanvasNodeRenderType.Default;
options: Partial<{
configurable: boolean;
configuration: boolean;
trigger: boolean;
}>;
};
export type CanvasNodeAddNodesRender = {
type: CanvasNodeRenderType.AddNodes;
options: Record<string, never>;
};
export interface CanvasNodeData {
id: INodeUi['id']; id: INodeUi['id'];
type: INodeUi['type']; type: INodeUi['type'];
typeVersion: INodeUi['typeVersion']; typeVersion: INodeUi['typeVersion'];
@ -64,13 +81,10 @@ export interface CanvasElementData {
count: number; count: number;
visible: boolean; visible: boolean;
}; };
render: { render: CanvasNodeDefaultRender | CanvasNodeAddNodesRender;
type: 'default';
options: Partial<{ configurable: boolean; configuration: boolean; trigger: boolean }>;
};
} }
export type CanvasElement = Node<CanvasElementData>; export type CanvasNode = Node<CanvasNodeData>;
export interface CanvasConnectionData { export interface CanvasConnectionData {
source: CanvasConnectionPort; source: CanvasConnectionPort;
@ -91,7 +105,7 @@ export interface CanvasPlugin {
export interface CanvasNodeInjectionData { export interface CanvasNodeInjectionData {
id: Ref<string>; id: Ref<string>;
data: Ref<CanvasElementData>; data: Ref<CanvasNodeData>;
label: Ref<NodeProps['label']>; label: Ref<NodeProps['label']>;
selected: Ref<NodeProps['selected']>; selected: Ref<NodeProps['selected']>;
nodeType: ComputedRef<INodeTypeDescription | null>; nodeType: ComputedRef<INodeTypeDescription | null>;

View file

@ -13,7 +13,7 @@ import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue'; import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import CanvasExecuteWorkflowButton from '@/components/canvas/elements/buttons/CanvasExecuteWorkflowButton.vue'; import CanvasRunWorkflowButton from '@/components/canvas/elements/buttons/CanvasRunWorkflowButton.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useRunWorkflow } from '@/composables/useRunWorkflow'; import { useRunWorkflow } from '@/composables/useRunWorkflow';
@ -27,15 +27,19 @@ import type {
XYPosition, XYPosition,
} from '@/Interface'; } from '@/Interface';
import type { Connection } from '@vue-flow/core'; import type { Connection } from '@vue-flow/core';
import type { CanvasElement, ConnectStartEvent } from '@/types'; import type { CanvasNode, ConnectStartEvent } from '@/types';
import { CanvasNodeRenderType } from '@/types';
import { import {
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT, CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT,
CHAT_TRIGGER_NODE_TYPE,
EnterpriseEditionFeature, EnterpriseEditionFeature,
MAIN_HEADER_TABS, MAIN_HEADER_TABS,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
MODAL_CANCEL, MODAL_CANCEL,
MODAL_CONFIRM, MODAL_CONFIRM,
NODE_CREATOR_OPEN_SOURCES, NODE_CREATOR_OPEN_SOURCES,
PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_EMPTY_WORKFLOW_ID,
START_NODE_TYPE,
VIEWS, VIEWS,
} from '@/constants'; } from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
@ -79,6 +83,7 @@ import { getNodeViewTab } from '@/utils/canvasUtils';
import { parseCanvasConnectionHandleString } from '@/utils/canvasUtilsV2'; import { parseCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
import CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue'; import CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue';
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue'; import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
import { nodeViewEventBus } from '@/event-bus';
const NodeCreation = defineAsyncComponent( const NodeCreation = defineAsyncComponent(
async () => await import('@/components/Node/NodeCreation.vue'), async () => await import('@/components/Node/NodeCreation.vue'),
@ -165,6 +170,25 @@ const isReadOnlyEnvironment = computed(() => {
return sourceControlStore.preferences.branchReadOnly; return sourceControlStore.preferences.branchReadOnly;
}); });
const isCanvasReadOnly = computed(() => {
return isLoading.value || isDemoRoute.value || isReadOnlyEnvironment.value;
});
const fallbackNodes = computed<INodeUi[]>(() =>
isCanvasReadOnly.value
? []
: [
{
id: CanvasNodeRenderType.AddNodes,
name: CanvasNodeRenderType.AddNodes,
type: CanvasNodeRenderType.AddNodes,
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
);
/** /**
* Initialization * Initialization
*/ */
@ -412,7 +436,20 @@ function makeNewWorkflowShareable() {
* Nodes * Nodes
*/ */
function onUpdateNodePosition(id: string, position: CanvasElement['position']) { const triggerNodes = computed(() => {
return editableWorkflow.value.nodes.filter(
(node) => node.type === START_NODE_TYPE || nodeTypesStore.isTriggerNode(node.type),
);
});
const containsTriggerNodes = computed(() => triggerNodes.value.length > 0);
const allTriggerNodesDisabled = computed(() => {
const disabledTriggerNodes = triggerNodes.value.filter((node) => node.disabled);
return disabledTriggerNodes.length === triggerNodes.value.length;
});
function onUpdateNodePosition(id: string, position: CanvasNode['position']) {
updateNodePosition(id, position, { trackHistory: true }); updateNodePosition(id, position, { trackHistory: true });
} }
@ -559,6 +596,18 @@ const isStoppingExecution = ref(false);
const isWorkflowRunning = computed(() => uiStore.isActionActive.workflowRunning); const isWorkflowRunning = computed(() => uiStore.isActionActive.workflowRunning);
const isExecutionWaitingForWebhook = computed(() => workflowsStore.executionWaitingForWebhook); const isExecutionWaitingForWebhook = computed(() => workflowsStore.executionWaitingForWebhook);
const isExecutionDisabled = computed(() => {
if (
containsChatTriggerNodes.value &&
isOnlyChatTriggerNodeActive.value &&
!chatTriggerNodePinnedData.value
) {
return true;
}
return !containsTriggerNodes.value || allTriggerNodesDisabled.value;
});
const isStopExecutionButtonVisible = computed( const isStopExecutionButtonVisible = computed(
() => isWorkflowRunning.value && !isExecutionWaitingForWebhook.value, () => isWorkflowRunning.value && !isExecutionWaitingForWebhook.value,
); );
@ -623,6 +672,43 @@ async function onStopWaitingForWebhook() {
await stopWaitingForWebhook(); await stopWaitingForWebhook();
} }
function onRunWorkflowButtonMouseEnter() {
nodeViewEventBus.emit('runWorkflowButton:mouseenter');
}
function onRunWorkflowButtonMouseLeave() {
nodeViewEventBus.emit('runWorkflowButton:mouseleave');
}
/**
* Chat
*/
const chatTriggerNode = computed(() => {
return editableWorkflow.value.nodes.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
});
const containsChatTriggerNodes = computed(() => {
return (
!isExecutionWaitingForWebhook.value &&
!!editableWorkflow.value.nodes.find(
(node) =>
[MANUAL_CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE].includes(node.type) &&
node.disabled !== true,
)
);
});
const isOnlyChatTriggerNodeActive = computed(() => {
return triggerNodes.value.every((node) => node.disabled || node.type === CHAT_TRIGGER_NODE_TYPE);
});
const chatTriggerNodePinnedData = computed(() => {
if (!chatTriggerNode.value) return null;
return workflowsStore.pinDataByNodeName(chatTriggerNode.value.name);
});
/** /**
* Keyboard * Keyboard
*/ */
@ -834,7 +920,7 @@ async function checkAndInitDebugMode() {
* Mouse events * Mouse events
*/ */
function onClickPane(position: CanvasElement['position']) { function onClickPane(position: CanvasNode['position']) {
lastClickPosition.value = [position.x, position.y]; lastClickPosition.value = [position.x, position.y];
canvasStore.newNodeInsertPosition = [position.x, position.y]; canvasStore.newNodeInsertPosition = [position.x, position.y];
} }
@ -967,6 +1053,7 @@ onBeforeUnmount(() => {
v-if="editableWorkflow && editableWorkflowObject" v-if="editableWorkflow && editableWorkflowObject"
:workflow="editableWorkflow" :workflow="editableWorkflow"
:workflow-object="editableWorkflowObject" :workflow-object="editableWorkflowObject"
:fallback-nodes="fallbackNodes"
@update:node:position="onUpdateNodePosition" @update:node:position="onUpdateNodePosition"
@update:node:active="onSetNodeActive" @update:node:active="onSetNodeActive"
@update:node:selected="onSetNodeSelected" @update:node:selected="onSetNodeSelected"
@ -979,9 +1066,12 @@ onBeforeUnmount(() => {
@click:pane="onClickPane" @click:pane="onClickPane"
> >
<div :class="$style.executionButtons"> <div :class="$style.executionButtons">
<CanvasExecuteWorkflowButton <CanvasRunWorkflowButton
:waiting-for-webhook="isExecutionWaitingForWebhook" :waiting-for-webhook="isExecutionWaitingForWebhook"
:disabled="isExecutionDisabled"
:executing="isWorkflowRunning" :executing="isWorkflowRunning"
@mouseenter="onRunWorkflowButtonMouseEnter"
@mouseleave="onRunWorkflowButtonMouseLeave"
@click="onRunWorkflow" @click="onRunWorkflow"
/> />
<CanvasStopCurrentExecutionButton <CanvasStopCurrentExecutionButton