mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 12:57:29 -08:00
feat(editor): Add node enable/disable functionality in new canvas (no-changelog) (#9872)
This commit is contained in:
parent
c39c087c20
commit
e995309789
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
|
@ -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', () => {
|
||||
|
|
|
@ -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],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue