1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-08-17 00:50:42 +03:00

feat(editor): Add node enable/disable functionality in new canvas (no-changelog) (#9872)

This commit is contained in:
Alex Grozav 2024-06-26 16:56:58 +03:00 committed by GitHub
parent c39c087c20
commit e995309789
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 489 additions and 46 deletions

View File

@ -6,16 +6,20 @@ export function createCanvasNodeData({
id = 'node',
type = 'test',
typeVersion = 1,
disabled = false,
inputs = [],
outputs = [],
connections = { input: {}, output: {} },
renderType = 'default',
}: Partial<CanvasElementData> = {}): CanvasElementData {
return {
id,
type,
typeVersion,
disabled,
inputs,
outputs,
connections,
renderType,
};
}

View File

@ -29,12 +29,14 @@ export const mockNode = ({
name,
type,
position = [0, 0],
disabled = false,
}: {
id?: INode['id'];
name: INode['name'];
type: INode['type'];
position?: INode['position'];
}) => mock<INode>({ id, name, type, position });
disabled?: INode['disabled'];
}) => mock<INode>({ id, name, type, position, disabled });
export const mockNodeTypeDescription = ({
name,

View File

@ -15,6 +15,7 @@ const emit = defineEmits<{
'update:modelValue': [elements: CanvasElement[]];
'update:node:position': [id: string, position: XYPosition];
'update:node:active': [id: string];
'update:node:enabled': [id: string];
'update:node:selected': [id?: string];
'delete:node': [id: string];
'delete:connection': [connection: Connection];
@ -66,6 +67,10 @@ function onSelectNode() {
emit('update:node:selected', selectedNodeId);
}
function onToggleNodeEnabled(id: string) {
emit('update:node:enabled', id);
}
function onDeleteNode(id: string) {
emit('delete:node', id);
}
@ -126,6 +131,7 @@ function onClickPane(event: MouseEvent) {
v-bind="canvasNodeProps"
@delete="onDeleteNode"
@select="onSelectNode"
@toggle="onToggleNodeEnabled"
@activate="onSetNodeActive"
/>
</template>

View File

@ -18,21 +18,24 @@ import type { NodeProps } from '@vue-flow/core';
const emit = defineEmits<{
delete: [id: string];
select: [id: string, selected: boolean];
toggle: [id: string];
activate: [id: string];
}>();
const props = defineProps<NodeProps<CanvasElementData>>();
const inputs = computed(() => props.data.inputs);
const outputs = computed(() => props.data.outputs);
const nodeTypesStore = useNodeTypesStore();
const inputs = computed(() => props.data.inputs);
const outputs = computed(() => props.data.outputs);
const connections = computed(() => props.data.connections);
const { mainInputs, nonMainInputs, mainOutputs, nonMainOutputs } = useNodeConnections({
inputs,
outputs,
connections,
});
const isDisabled = computed(() => props.data.disabled);
const nodeType = computed(() => {
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
});
@ -107,6 +110,10 @@ function onDelete() {
emit('delete', props.id);
}
function onDisabledToggle() {
emit('toggle', props.id);
}
function onActivate() {
emit('activate', props.id);
}
@ -143,12 +150,12 @@ function onActivate() {
data-test-id="canvas-node-toolbar"
:class="$style.canvasNodeToolbar"
@delete="onDelete"
@toggle="onDisabledToggle"
/>
<CanvasNodeRenderer v-if="nodeType" @dblclick="onActivate">
<NodeIcon :node-type="nodeType" :size="40" :shrink="false" />
<NodeIcon :node-type="nodeType" :size="40" :shrink="false" :disabled="isDisabled" />
<!-- :color-default="iconColorDefault"-->
<!-- :disabled="data.disabled"-->
</CanvasNodeRenderer>
</div>
</template>

View File

@ -53,39 +53,39 @@ describe('CanvasNodeToolbar', () => {
});
it('should call toggleDisableNode function when disable node button is clicked', async () => {
const toggleDisableNode = vi.fn();
const onToggleNode = vi.fn();
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
mocks: {
toggleDisableNode,
onToggleNode,
},
},
});
await fireEvent.click(getByTestId('disable-node-button'));
expect(toggleDisableNode).toHaveBeenCalled();
expect(onToggleNode).toHaveBeenCalled();
});
it('should call deleteNode function when delete node button is clicked', async () => {
const deleteNode = vi.fn();
const onDeleteNode = vi.fn();
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
mocks: {
deleteNode,
onDeleteNode,
},
},
});
await fireEvent.click(getByTestId('delete-node-button'));
expect(deleteNode).toHaveBeenCalled();
expect(onDeleteNode).toHaveBeenCalled();
});
it('should call openContextMenu function when overflow node button is clicked', async () => {

View File

@ -3,12 +3,14 @@ import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
import { useI18n } from '@/composables/useI18n';
const emit = defineEmits(['delete']);
const emit = defineEmits<{
delete: [];
toggle: [];
}>();
const $style = useCssModule();
const node = inject(CanvasNodeKey);
const i18n = useI18n();
const node = inject(CanvasNodeKey);
const data = computed(() => node?.data.value);
@ -21,10 +23,11 @@ const nodeDisabledTitle = 'Test';
// @TODO
function executeNode() {}
// @TODO
function toggleDisableNode() {}
function onToggleNode() {
emit('toggle');
}
function deleteNode() {
function onDeleteNode() {
emit('delete');
}
@ -53,7 +56,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
size="small"
icon="power-off"
:title="nodeDisabledTitle"
@click="toggleDisableNode"
@click="onToggleNode"
/>
<N8nIconButton
data-test-id="delete-node-button"
@ -62,7 +65,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
text
icon="trash"
:title="i18n.baseText('node.delete')"
@click="deleteNode"
@click="onDeleteNode"
/>
<N8nIconButton
data-test-id="overflow-node-button"

View File

@ -44,6 +44,36 @@ describe('CanvasNodeConfigurable', () => {
});
});
describe('disabled', () => {
it('should apply disabled class when node is disabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
disabled: true,
},
}),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('disabled');
expect(getByText('(Deactivated)')).toBeVisible();
});
it('should not apply disabled class when node is enabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node').closest('.node')).not.toHaveClass('disabled');
});
});
describe('inputs', () => {
it('should adjust width css variable based on the number of non-main inputs', () => {
const { getByText } = renderComponent({

View File

@ -2,24 +2,32 @@
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey, NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { useI18n } from '@/composables/useI18n';
import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue';
const node = inject(CanvasNodeKey);
const $style = useCssModule();
const i18n = useI18n();
const label = computed(() => node?.label.value ?? '');
const inputs = computed(() => node?.data.value.inputs ?? []);
const outputs = computed(() => node?.data.value.outputs ?? []);
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
const { nonMainInputs, requiredNonMainInputs } = useNodeConnections({
inputs,
outputs,
connections,
});
const isDisabled = computed(() => node?.data.value.disabled ?? false);
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: node?.selected.value,
[$style.disabled]: isDisabled.value,
};
});
@ -46,7 +54,11 @@ const styles = computed(() => {
<template>
<div :class="classes" :style="styles" data-test-id="canvas-node-configurable">
<slot />
<div :class="$style.label">{{ label }}</div>
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" />
<div :class="$style.label">
{{ label }}
<div v-if="isDisabled">({{ i18n.baseText('node.disabled') }})</div>
</div>
</div>
</template>
@ -54,12 +66,14 @@ const styles = computed(() => {
.node {
--configurable-node-min-input-count: 4;
--configurable-node-input-width: 65px;
width: calc(
--canvas-node--height: 100px;
--canvas-node--width: calc(
max(var(--configurable-node-input-count, 5), var(--configurable-node-min-input-count)) *
var(--configurable-node-input-width)
);
height: 100px;
width: var(--canvas-node--width);
height: var(--canvas-node--height);
display: flex;
align-items: center;
justify-content: center;
@ -82,4 +96,8 @@ const styles = computed(() => {
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
}
</style>

View File

@ -40,4 +40,34 @@ describe('CanvasNodeConfiguration', () => {
expect(getByText('Test Node').closest('.node')).not.toHaveClass('selected');
});
});
describe('disabled', () => {
it('should apply disabled class when node is disabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
disabled: true,
},
}),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('disabled');
expect(getByText('(Deactivated)')).toBeVisible();
});
it('should not apply disabled class when node is enabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node').closest('.node')).not.toHaveClass('disabled');
});
});
});

View File

@ -1,17 +1,22 @@
<script lang="ts" setup>
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
import { useI18n } from '@/composables/useI18n';
const node = inject(CanvasNodeKey);
const $style = useCssModule();
const i18n = useI18n();
const label = computed(() => node?.label.value ?? '');
const isDisabled = computed(() => node?.data.value.disabled ?? false);
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: node?.selected.value,
[$style.disabled]: isDisabled.value,
};
});
</script>
@ -19,14 +24,20 @@ const classes = computed(() => {
<template>
<div :class="classes" data-test-id="canvas-node-configuration">
<slot />
<div v-if="label" :class="$style.label">{{ label }}</div>
<div v-if="label" :class="$style.label">
{{ label }}
<div v-if="isDisabled">({{ i18n.baseText('node.disabled') }})</div>
</div>
</div>
</template>
<style lang="scss" module>
.node {
width: 75px;
height: 75px;
--canvas-node--width: 75px;
--canvas-node--height: 75px;
width: var(--canvas-node--width);
height: var(--canvas-node--height);
display: flex;
align-items: center;
justify-content: center;
@ -35,10 +46,6 @@ const classes = computed(() => {
border-radius: 50%;
}
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
.label {
top: 100%;
position: absolute;
@ -48,4 +55,12 @@ const classes = computed(() => {
min-width: 200px;
margin-top: var(--spacing-2xs);
}
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
}
</style>

View File

@ -81,4 +81,34 @@ describe('CanvasNodeDefault', () => {
expect(getByText('Test Node').closest('.node')).not.toHaveClass('selected');
});
});
describe('disabled', () => {
it('should apply disabled class when node is disabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
disabled: true,
},
}),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('disabled');
expect(getByText('(Deactivated)')).toBeVisible();
});
it('should not apply disabled class when node is enabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node').closest('.node')).not.toHaveClass('disabled');
});
});
});

View File

@ -2,24 +2,32 @@
import { computed, inject, useCssModule } from 'vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue';
const node = inject(CanvasNodeKey);
const $style = useCssModule();
const i18n = useI18n();
const label = computed(() => node?.label.value ?? '');
const inputs = computed(() => node?.data.value.inputs ?? []);
const outputs = computed(() => node?.data.value.outputs ?? []);
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
const { mainOutputs } = useNodeConnections({
inputs,
outputs,
connections,
});
const isDisabled = computed(() => node?.data.value.disabled ?? false);
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: node?.selected.value,
[$style.disabled]: isDisabled.value,
};
});
@ -33,14 +41,21 @@ const styles = computed(() => {
<template>
<div v-if="node" :class="classes" :style="styles" data-test-id="canvas-node-default">
<slot />
<div v-if="label" :class="$style.label">{{ label }}</div>
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" />
<div v-if="label" :class="$style.label">
{{ label }}
<div v-if="isDisabled">({{ i18n.baseText('node.disabled') }})</div>
</div>
</div>
</template>
<style lang="scss" module>
.node {
height: calc(100px + max(0, var(--node-main-output-count, 1) - 4) * 50px);
width: 100px;
--canvas-node--height: calc(100px + max(0, var(--node-main-output-count, 1) - 4) * 50px);
--canvas-node--width: 100px;
height: var(--canvas-node--height);
width: var(--canvas-node--width);
display: flex;
align-items: center;
justify-content: center;
@ -62,4 +77,8 @@ const styles = computed(() => {
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
}
</style>

View File

@ -0,0 +1,35 @@
import CanvasNodeDisabledStrikeThrough from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeDisabledStrikeThrough.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasNodeDisabledStrikeThrough);
describe('CanvasNodeDisabledStrikeThrough', () => {
it('should render node correctly', () => {
const { container } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
connections: {
input: {
[NodeConnectionType.Main]: [
[{ node: 'node', type: NodeConnectionType.Main, index: 0 }],
],
},
output: {
[NodeConnectionType.Main]: [
[{ node: 'node', type: NodeConnectionType.Main, index: 0 }],
],
},
},
},
}),
},
},
});
expect(container.firstChild).toBeVisible();
});
});

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
import { useNodeConnections } from '@/composables/useNodeConnections';
const $style = useCssModule();
const node = inject(CanvasNodeKey);
const inputs = computed(() => node?.data.value.inputs ?? []);
const outputs = computed(() => node?.data.value.outputs ?? []);
const connections = computed(() => node?.data.value.connections ?? { input: {}, output: {} });
const { mainInputConnections, mainOutputConnections } = useNodeConnections({
inputs,
outputs,
connections,
});
const isVisible = computed(
() => mainInputConnections.value.length === 1 && mainOutputConnections.value.length === 1,
);
const isSuccessStatus = computed(
() => false,
// @TODO Implement this
// () => !['unknown'].includes(node.status) && workflowDataItems > 0,
);
const classes = computed(() => {
return {
[$style.disabledStrikeThrough]: true,
[$style.success]: isSuccessStatus.value,
};
});
</script>
<template>
<div v-if="isVisible" :class="classes"></div>
</template>
<style lang="scss" module>
.disabledStrikeThrough {
border: 1px solid var(--color-foreground-dark);
position: absolute;
top: calc(var(--canvas-node--height) / 2 - 1px);
left: -4px;
width: calc(100% + 12px);
pointer-events: none;
}
.success {
border-color: var(--color-success-light);
}
</style>

View File

@ -7,7 +7,8 @@ import { mock } from 'vitest-mock-extended';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import type { IWorkflowDb } from '@/Interface';
import { createTestWorkflowObject, mockNodes } from '@/__tests__/mocks';
import { createTestWorkflowObject, mockNode, mockNodes } from '@/__tests__/mocks';
import { MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
@ -48,7 +49,11 @@ describe('useCanvasMapping', () => {
describe('elements', () => {
it('should map nodes to canvas elements', () => {
const manualTriggerNode = mockNodes[0];
const manualTriggerNode = mockNode({
name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE,
disabled: false,
});
const workflow = mock<IWorkflowDb>({
nodes: [manualTriggerNode],
});
@ -69,13 +74,75 @@ describe('useCanvasMapping', () => {
id: manualTriggerNode.id,
type: manualTriggerNode.type,
typeVersion: expect.anything(),
disabled: false,
inputs: [],
outputs: [],
connections: {
input: {},
output: {},
},
renderType: 'default',
},
},
]);
});
it('should handle node disabled state', () => {
const manualTriggerNode = mockNode({
name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE,
disabled: true,
});
const workflow = mock<IWorkflowDb>({
nodes: [manualTriggerNode],
});
const workflowObject = createTestWorkflowObject(workflow);
const { elements } = useCanvasMapping({
workflow: ref(workflow),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(elements.value[0]?.data?.disabled).toEqual(true);
});
it('should handle input and output connections', () => {
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
const workflow = mock<IWorkflowDb>({
nodes: [manualTriggerNode, setNode],
connections: {
[manualTriggerNode.name]: {
[NodeConnectionType.Main]: [
[{ node: setNode.name, type: NodeConnectionType.Main, index: 0 }],
],
},
},
});
const workflowObject = createTestWorkflowObject(workflow);
const { elements } = useCanvasMapping({
workflow: ref(workflow),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(elements.value[0]?.data?.connections.output).toHaveProperty(NodeConnectionType.Main);
expect(elements.value[0]?.data?.connections.output[NodeConnectionType.Main][0][0]).toEqual(
expect.objectContaining({
node: setNode.name,
type: NodeConnectionType.Main,
index: 0,
}),
);
expect(elements.value[1]?.data?.connections.input).toHaveProperty(NodeConnectionType.Main);
expect(elements.value[1]?.data?.connections.input[NodeConnectionType.Main][0][0]).toEqual(
expect.objectContaining({
node: manualTriggerNode.name,
type: NodeConnectionType.Main,
index: 0,
}),
);
});
});
describe('connections', () => {

View File

@ -4,6 +4,7 @@ import { useNodeConnections } from '@/composables/useNodeConnections';
import type { CanvasElementData } from '@/types';
describe('useNodeConnections', () => {
const defaultConnections = { input: {}, output: {} };
describe('mainInputs', () => {
it('should return main inputs when provided with main inputs', () => {
const inputs = ref<CanvasElementData['inputs']>([
@ -14,7 +15,11 @@ describe('useNodeConnections', () => {
]);
const outputs = ref<CanvasElementData['outputs']>([]);
const { mainInputs } = useNodeConnections({ inputs, outputs });
const { mainInputs } = useNodeConnections({
inputs,
outputs,
connections: defaultConnections,
});
expect(mainInputs.value.length).toBe(3);
expect(mainInputs.value).toEqual(inputs.value.slice(0, 3));
@ -30,7 +35,11 @@ describe('useNodeConnections', () => {
]);
const outputs = ref<CanvasElementData['outputs']>([]);
const { nonMainInputs } = useNodeConnections({ inputs, outputs });
const { nonMainInputs } = useNodeConnections({
inputs,
outputs,
connections: defaultConnections,
});
expect(nonMainInputs.value.length).toBe(2);
expect(nonMainInputs.value).toEqual(inputs.value.slice(1));
@ -46,13 +55,42 @@ describe('useNodeConnections', () => {
]);
const outputs = ref<CanvasElementData['outputs']>([]);
const { requiredNonMainInputs } = useNodeConnections({ inputs, outputs });
const { requiredNonMainInputs } = useNodeConnections({
inputs,
outputs,
connections: defaultConnections,
});
expect(requiredNonMainInputs.value.length).toBe(1);
expect(requiredNonMainInputs.value).toEqual([inputs.value[1]]);
});
});
describe('mainInputConnections', () => {
it('should return main input connections when provided with main input connections', () => {
const inputs = ref<CanvasElementData['inputs']>([]);
const outputs = ref<CanvasElementData['outputs']>([]);
const connections = ref<CanvasElementData['connections']>({
input: {
[NodeConnectionType.Main]: [
[{ node: 'node1', type: NodeConnectionType.Main, index: 0 }],
[{ node: 'node2', type: NodeConnectionType.Main, index: 0 }],
],
},
output: {},
});
const { mainInputConnections } = useNodeConnections({
inputs,
outputs,
connections,
});
expect(mainInputConnections.value.length).toBe(2);
expect(mainInputConnections.value).toEqual(connections.value.input[NodeConnectionType.Main]);
});
});
describe('mainOutputs', () => {
it('should return main outputs when provided with main outputs', () => {
const inputs = ref<CanvasElementData['inputs']>([]);
@ -63,7 +101,11 @@ describe('useNodeConnections', () => {
{ type: NodeConnectionType.AiAgent, index: 0 },
]);
const { mainOutputs } = useNodeConnections({ inputs, outputs });
const { mainOutputs } = useNodeConnections({
inputs,
outputs,
connections: defaultConnections,
});
expect(mainOutputs.value.length).toBe(3);
expect(mainOutputs.value).toEqual(outputs.value.slice(0, 3));
@ -79,10 +121,41 @@ describe('useNodeConnections', () => {
{ type: NodeConnectionType.AiAgent, index: 1 },
]);
const { nonMainOutputs } = useNodeConnections({ inputs, outputs });
const { nonMainOutputs } = useNodeConnections({
inputs,
outputs,
connections: defaultConnections,
});
expect(nonMainOutputs.value.length).toBe(2);
expect(nonMainOutputs.value).toEqual(outputs.value.slice(1));
});
});
describe('mainOutputConnections', () => {
it('should return main output connections when provided with main output connections', () => {
const inputs = ref<CanvasElementData['inputs']>([]);
const outputs = ref<CanvasElementData['outputs']>([]);
const connections = ref<CanvasElementData['connections']>({
input: {},
output: {
[NodeConnectionType.Main]: [
[{ node: 'node1', type: NodeConnectionType.Main, index: 0 }],
[{ node: 'node2', type: NodeConnectionType.Main, index: 0 }],
],
},
});
const { mainOutputConnections } = useNodeConnections({
inputs,
outputs,
connections,
});
expect(mainOutputConnections.value.length).toBe(2);
expect(mainOutputConnections.value).toEqual(
connections.value.output[NodeConnectionType.Main],
);
});
});
});

View File

@ -89,12 +89,20 @@ export function useCanvasMapping({
const elements = computed<CanvasElement[]>(() => [
...workflow.value.nodes.map<CanvasElement>((node) => {
const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {};
const outputConnections = workflowObject.value.connectionsBySourceNode[node.name] ?? {};
const data: CanvasElementData = {
id: node.id,
type: node.type,
typeVersion: node.typeVersion,
disabled: !!node.disabled,
inputs: nodeInputsById.value[node.id] ?? [],
outputs: nodeOutputsById.value[node.id] ?? [],
connections: {
input: inputConnections,
output: outputConnections,
},
renderType: renderTypeByNodeType.value[node.type] ?? 'default',
};

View File

@ -46,6 +46,7 @@ import { useCredentialsStore } from '@/stores/credentials.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import type { useRouter } from 'vue-router';
import { useCanvasStore } from '@/stores/canvas.store';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
type AddNodeData = {
name?: string;
@ -78,6 +79,7 @@ export function useCanvasOperations({
const i18n = useI18n();
const toast = useToast();
const workflowHelpers = useWorkflowHelpers({ router });
const nodeHelpers = useNodeHelpers();
const telemetry = useTelemetry();
const externalHooks = useExternalHooks();
@ -235,6 +237,18 @@ export function useCanvasOperations({
uiStore.lastSelectedNode = node.name;
}
function toggleNodeDisabled(
id: string,
{ trackHistory = true }: { trackHistory?: boolean } = {},
) {
const node = workflowsStore.getNodeById(id);
if (!node) {
return;
}
nodeHelpers.disableNodes([node], trackHistory);
}
async function addNodes(
nodes: AddedNodesAndConnections['nodes'],
{
@ -886,6 +900,7 @@ export function useCanvasOperations({
setNodeActive,
setNodeActiveByName,
setNodeSelected,
toggleNodeDisabled,
renameNode,
revertRenameNode,
deleteNode,

View File

@ -6,9 +6,11 @@ import { NodeConnectionType } from 'n8n-workflow';
export function useNodeConnections({
inputs,
outputs,
connections,
}: {
inputs: MaybeRef<CanvasElementData['inputs']>;
outputs: MaybeRef<CanvasElementData['outputs']>;
connections: MaybeRef<CanvasElementData['connections']>;
}) {
/**
* Inputs
@ -26,6 +28,10 @@ export function useNodeConnections({
nonMainInputs.value.filter((input) => input.required),
);
const mainInputConnections = computed(
() => unref(connections).input[NodeConnectionType.Main] ?? [],
);
/**
* Outputs
*/
@ -33,15 +39,22 @@ export function useNodeConnections({
const mainOutputs = computed(() =>
unref(outputs).filter((output) => output.type === NodeConnectionType.Main),
);
const nonMainOutputs = computed(() =>
unref(outputs).filter((output) => output.type !== NodeConnectionType.Main),
);
const mainOutputConnections = computed(
() => unref(connections).output[NodeConnectionType.Main] ?? [],
);
return {
mainInputs,
nonMainInputs,
requiredNonMainInputs,
mainInputConnections,
mainOutputs,
nonMainOutputs,
mainOutputConnections,
};
}

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
import type { ConnectionTypes, INodeTypeDescription } from 'n8n-workflow';
import type { ConnectionTypes, INodeConnections, INodeTypeDescription } from 'n8n-workflow';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core';
import type { INodeUi } from '@/Interface';
@ -25,8 +25,13 @@ export interface CanvasElementData {
id: INodeUi['id'];
type: INodeUi['type'];
typeVersion: INodeUi['typeVersion'];
disabled: INodeUi['disabled'];
inputs: CanvasConnectionPort[];
outputs: CanvasConnectionPort[];
connections: {
input: INodeConnections;
output: INodeConnections;
};
renderType: 'default' | 'trigger' | 'configuration' | 'configurable';
}

View File

@ -95,6 +95,7 @@ const {
revertRenameNode,
setNodeActive,
setNodeSelected,
toggleNodeDisabled,
deleteNode,
revertDeleteNode,
addNodes,
@ -363,6 +364,14 @@ function onRevertDeleteNode({ node }: { node: INodeUi }) {
revertDeleteNode(node);
}
function onToggleNodeDisabled(id: string) {
if (!checkIfEditingIsAllowed()) {
return;
}
toggleNodeDisabled(id);
}
function onSetNodeActive(id: string) {
setNodeActive(id);
}
@ -680,6 +689,7 @@ onBeforeUnmount(() => {
@update:node:position="onUpdateNodePosition"
@update:node:active="onSetNodeActive"
@update:node:selected="onSetNodeSelected"
@update:node:enabled="onToggleNodeDisabled"
@delete:node="onDeleteNode"
@create:connection="onCreateConnection"
@delete:connection="onDeleteConnection"