feat(editor): Add initial code for NodeView and Canvas rewrite (no-changelog) (#9135)
Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com>
This commit is contained in:
112
packages/editor-ui/src/components/canvas/Canvas.spec.ts
Normal file
112
packages/editor-ui/src/components/canvas/Canvas.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import Canvas from '@/components/canvas/Canvas.vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import type { CanvasConnection, CanvasElement } from '@/types';
|
||||
import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/data';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
global.window = jsdom.window as unknown as Window & typeof globalThis;
|
||||
|
||||
vi.mock('@/stores/nodeTypes.store', () => ({
|
||||
useNodeTypesStore: vi.fn(() => ({
|
||||
getNodeType: vi.fn(() => ({
|
||||
name: 'test',
|
||||
description: 'Test Node Description',
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
beforeEach(() => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
renderComponent = createComponentRenderer(Canvas, { pinia });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Canvas', () => {
|
||||
it('should initialize with default props', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
expect(getByTestId('canvas')).toBeVisible();
|
||||
expect(getByTestId('canvas-background')).toBeVisible();
|
||||
expect(getByTestId('canvas-minimap')).toBeVisible();
|
||||
expect(getByTestId('canvas-controls')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render nodes and edges', async () => {
|
||||
const elements: CanvasElement[] = [
|
||||
createCanvasNodeElement({
|
||||
id: '1',
|
||||
label: 'Node 1',
|
||||
data: {
|
||||
outputs: [
|
||||
{
|
||||
type: NodeConnectionType.Main,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createCanvasNodeElement({
|
||||
id: '2',
|
||||
label: 'Node 2',
|
||||
position: { x: 200, y: 200 },
|
||||
data: {
|
||||
inputs: [
|
||||
{
|
||||
type: NodeConnectionType.Main,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const connections: CanvasConnection[] = [createCanvasConnection(elements[0], elements[1])];
|
||||
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
elements,
|
||||
connections,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(2));
|
||||
|
||||
expect(container.querySelector(`[data-id="${elements[0].id}"]`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-id="${elements[1].id}"]`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-id="${connections[0].id}"]`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle node drag stop event', async () => {
|
||||
const elements = [createCanvasNodeElement()];
|
||||
const { container, emitted } = renderComponent({
|
||||
props: {
|
||||
elements,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
|
||||
|
||||
const node = container.querySelector(`[data-id="${elements[0].id}"]`) as Element;
|
||||
await fireEvent.mouseDown(node, { view: window });
|
||||
await fireEvent.mouseMove(node, {
|
||||
view: window,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
await fireEvent.mouseUp(node, { view: window });
|
||||
|
||||
expect(emitted()['update:node:position']).toEqual([['1', { x: 100, y: 100 }]]);
|
||||
});
|
||||
});
|
||||
117
packages/editor-ui/src/components/canvas/Canvas.vue
Normal file
117
packages/editor-ui/src/components/canvas/Canvas.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CanvasConnection, CanvasElement } from '@/types';
|
||||
import type { NodeDragEvent, Connection } from '@vue-flow/core';
|
||||
import { VueFlow, PanelPosition } from '@vue-flow/core';
|
||||
import { Background } from '@vue-flow/background';
|
||||
import { Controls } from '@vue-flow/controls';
|
||||
import { MiniMap } from '@vue-flow/minimap';
|
||||
import CanvasNode from './elements/nodes/CanvasNode.vue';
|
||||
import CanvasEdge from './elements/edges/CanvasEdge.vue';
|
||||
import { useCssModule } from 'vue';
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [elements: CanvasElement[]];
|
||||
'update:node:position': [id: string, position: { x: number; y: number }];
|
||||
'create:connection': [connection: Connection];
|
||||
}>();
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
id?: string;
|
||||
elements: CanvasElement[];
|
||||
connections: CanvasConnection[];
|
||||
controlsPosition?: PanelPosition;
|
||||
}>(),
|
||||
{
|
||||
id: 'canvas',
|
||||
elements: () => [],
|
||||
connections: () => [],
|
||||
controlsPosition: PanelPosition.BottomLeft,
|
||||
},
|
||||
);
|
||||
|
||||
function onNodeDragStop(e: NodeDragEvent) {
|
||||
e.nodes.forEach((node) => {
|
||||
emit('update:node:position', node.id, node.position);
|
||||
});
|
||||
}
|
||||
|
||||
function onConnect(...args: unknown[]) {
|
||||
emit('create:connection', args[0] as Connection);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VueFlow
|
||||
:id="id"
|
||||
:nodes="elements"
|
||||
:edges="connections"
|
||||
:apply-changes="false"
|
||||
fit-view-on-init
|
||||
pan-on-scroll
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="2"
|
||||
data-test-id="canvas"
|
||||
@node-drag-stop="onNodeDragStop"
|
||||
@connect="onConnect"
|
||||
>
|
||||
<template #node-canvas-node="canvasNodeProps">
|
||||
<CanvasNode v-bind="canvasNodeProps" />
|
||||
</template>
|
||||
|
||||
<template #edge-canvas-edge="canvasEdgeProps">
|
||||
<CanvasEdge v-bind="canvasEdgeProps" />
|
||||
</template>
|
||||
|
||||
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="16" />
|
||||
|
||||
<MiniMap data-test-id="canvas-minimap" pannable />
|
||||
|
||||
<Controls
|
||||
data-test-id="canvas-controls"
|
||||
:class="$style.canvasControls"
|
||||
:position="controlsPosition"
|
||||
></Controls>
|
||||
</VueFlow>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
|
||||
<style lang="scss">
|
||||
.vue-flow__controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.vue-flow__controls-button {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: var(--border-base);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: 0;
|
||||
transition-property: transform, background, border, color;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-button-secondary-hover-active-border);
|
||||
background-color: var(--color-button-secondary-active-background);
|
||||
transform: scale(1.1);
|
||||
|
||||
svg {
|
||||
fill: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
max-height: 16px;
|
||||
max-width: 16px;
|
||||
transition-property: fill;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
72
packages/editor-ui/src/components/canvas/WorkflowCanvas.vue
Normal file
72
packages/editor-ui/src/components/canvas/WorkflowCanvas.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import Canvas from '@/components/canvas/Canvas.vue';
|
||||
import { toRef, useCssModule } from 'vue';
|
||||
import type { Workflow } from 'n8n-workflow';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
||||
|
||||
const props = defineProps<{
|
||||
id?: string;
|
||||
workflow: IWorkflowDb;
|
||||
workflowObject: Workflow;
|
||||
}>();
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
const workflow = toRef(props, 'workflow');
|
||||
const workflowObject = toRef(props, 'workflowObject');
|
||||
|
||||
const { elements, connections } = useCanvasMapping({ workflow, workflowObject });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.wrapper">
|
||||
<div :class="$style.canvas">
|
||||
<Canvas v-if="workflow" :elements="elements" :connections="connections" v-bind="$attrs" />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrapper {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.executionButtons {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: var(--spacing-l);
|
||||
width: auto;
|
||||
|
||||
@media (max-width: $breakpoint-2xs) {
|
||||
bottom: 150px;
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-left: 0.625rem;
|
||||
|
||||
&:first-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
defineEmits(['click']);
|
||||
|
||||
const uiStore = useUIStore();
|
||||
const locale = useI18n();
|
||||
|
||||
const workflowRunning = computed(() => uiStore.isActionActive('workflowRunning'));
|
||||
|
||||
const runButtonText = computed(() => {
|
||||
if (!workflowRunning.value) {
|
||||
return locale.baseText('nodeView.runButtonText.executeWorkflow');
|
||||
}
|
||||
|
||||
return locale.baseText('nodeView.runButtonText.executingWorkflow');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KeyboardShortcutTooltip :label="runButtonText" :shortcut="{ metaKey: true, keys: ['↵'] }">
|
||||
<N8nButton
|
||||
:loading="workflowRunning"
|
||||
:label="runButtonText"
|
||||
size="large"
|
||||
icon="flask"
|
||||
type="primary"
|
||||
data-test-id="execute-workflow-button"
|
||||
@click.stop="$emit('click', $event)"
|
||||
/>
|
||||
</KeyboardShortcutTooltip>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EdgeProps } from '@vue-flow/core';
|
||||
import { BaseEdge, getBezierPath } from '@vue-flow/core';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<EdgeProps>();
|
||||
|
||||
const edgeStyle = computed(() => ({
|
||||
strokeWidth: 2,
|
||||
...props.style,
|
||||
}));
|
||||
|
||||
const path = computed(() =>
|
||||
getBezierPath({
|
||||
sourceX: props.sourceX,
|
||||
sourceY: props.sourceY,
|
||||
sourcePosition: props.sourcePosition,
|
||||
targetX: props.targetX,
|
||||
targetY: props.targetY,
|
||||
targetPosition: props.targetPosition,
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseEdge
|
||||
:id="id"
|
||||
:style="edgeStyle"
|
||||
:path="path[0]"
|
||||
:marker-end="markerEnd"
|
||||
:label="data?.label"
|
||||
:label-x="path[1]"
|
||||
:label-y="path[2]"
|
||||
:label-style="{ fill: 'white' }"
|
||||
:label-show-bg="true"
|
||||
:label-bg-style="{ fill: 'red' }"
|
||||
:label-bg-padding="[2, 4]"
|
||||
:label-bg-border-radius="2"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
import HandleRenderer from '@/components/canvas/elements/handles/HandleRenderer.vue';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { CanvasNodeHandleKey } from '@/constants';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const renderComponent = createComponentRenderer(HandleRenderer);
|
||||
|
||||
const Handle = {
|
||||
template: '<div><slot /></div>',
|
||||
};
|
||||
|
||||
describe('HandleRenderer', () => {
|
||||
it('should render the main input handle correctly', async () => {
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
mode: 'input',
|
||||
type: NodeConnectionType.Main,
|
||||
index: 0,
|
||||
position: 'left',
|
||||
offset: { left: '10px', top: '10px' },
|
||||
label: 'Main Input',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Handle,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.querySelector('.handle')).toBeInTheDocument();
|
||||
expect(container.querySelector('.canvas-node-handle-main-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the main output handle correctly', async () => {
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
mode: 'output',
|
||||
type: NodeConnectionType.Main,
|
||||
index: 0,
|
||||
position: 'right',
|
||||
offset: { right: '10px', bottom: '10px' },
|
||||
label: 'Main Output',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Handle,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.querySelector('.handle')).toBeInTheDocument();
|
||||
expect(container.querySelector('.canvas-node-handle-main-output')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the non-main handle correctly', async () => {
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
mode: 'input',
|
||||
type: NodeConnectionType.AiTool,
|
||||
index: 0,
|
||||
position: 'top',
|
||||
offset: { top: '10px', left: '5px' },
|
||||
label: 'AI Tool Input',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Handle,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.querySelector('.handle')).toBeInTheDocument();
|
||||
expect(container.querySelector('.canvas-node-handle-non-main')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide the label correctly', async () => {
|
||||
const label = 'Test Label';
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
mode: 'input',
|
||||
type: NodeConnectionType.AiTool,
|
||||
index: 0,
|
||||
position: 'top',
|
||||
offset: { top: '10px', left: '5px' },
|
||||
label,
|
||||
},
|
||||
global: {
|
||||
provide: {
|
||||
[`${CanvasNodeHandleKey}`]: { label: ref(label) },
|
||||
},
|
||||
stubs: {
|
||||
Handle,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, provide, toRef, useCssModule } from 'vue';
|
||||
import type { CanvasConnectionPort, CanvasElementPortWithPosition } from '@/types';
|
||||
|
||||
import { Handle } from '@vue-flow/core';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import CanvasHandleMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue';
|
||||
import CanvasHandleMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue';
|
||||
import CanvasHandleNonMain from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMain.vue';
|
||||
import { CanvasNodeHandleKey } from '@/constants';
|
||||
|
||||
const props = defineProps<{
|
||||
mode: 'output' | 'input';
|
||||
label?: string;
|
||||
type: CanvasConnectionPort['type'];
|
||||
index: CanvasConnectionPort['index'];
|
||||
position: CanvasElementPortWithPosition['position'];
|
||||
offset: CanvasElementPortWithPosition['offset'];
|
||||
}>();
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
const handleType = computed(() => (props.mode === 'input' ? 'target' : 'source'));
|
||||
|
||||
const isConnectableStart = computed(() => {
|
||||
return props.mode === 'output';
|
||||
});
|
||||
|
||||
const isConnectableEnd = computed(() => {
|
||||
return props.mode === 'input';
|
||||
});
|
||||
|
||||
const Render = (renderProps: { label?: string }) => {
|
||||
let Component;
|
||||
|
||||
if (props.type === NodeConnectionType.Main) {
|
||||
if (props.mode === 'input') {
|
||||
Component = CanvasHandleMainInput;
|
||||
} else {
|
||||
Component = CanvasHandleMainOutput;
|
||||
}
|
||||
} else {
|
||||
Component = CanvasHandleNonMain;
|
||||
}
|
||||
|
||||
return h(Component, renderProps);
|
||||
};
|
||||
|
||||
/**
|
||||
* Provide
|
||||
*/
|
||||
|
||||
const label = toRef(props, 'label');
|
||||
|
||||
provide(CanvasNodeHandleKey, {
|
||||
label,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Handle
|
||||
:id="`${mode}s/${type}/${index}`"
|
||||
:class="[$style.handle]"
|
||||
:type="handleType"
|
||||
:position="position"
|
||||
:style="offset"
|
||||
:connectable-start="isConnectableStart"
|
||||
:connectable-end="isConnectableEnd"
|
||||
>
|
||||
<Render :label="label" />
|
||||
</Handle>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.handle {
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
background: none;
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
import CanvasHandleMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { CanvasNodeHandleKey } from '@/constants';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasHandleMainInput);
|
||||
|
||||
describe('CanvasHandleMainInput', () => {
|
||||
it('should render correctly', async () => {
|
||||
const label = 'Test Label';
|
||||
const { container, getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
[`${CanvasNodeHandleKey}`]: { label: ref(label) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.querySelector('.canvas-node-handle-main-input')).toBeInTheDocument();
|
||||
expect(getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, useCssModule } from 'vue';
|
||||
import { CanvasNodeHandleKey } from '@/constants';
|
||||
|
||||
const handle = inject(CanvasNodeHandleKey);
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
const label = computed(() => handle?.label.value ?? '');
|
||||
</script>
|
||||
<template>
|
||||
<div :class="['canvas-node-handle-main-input', $style.handle]">
|
||||
<div :class="$style.label">{{ label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.handle {
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
border-radius: 0;
|
||||
background: var(--color-foreground-xdark);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translate(0, -50%);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-foreground-xdark);
|
||||
background: var(--color-background-light);
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
import CanvasHandleMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { CanvasNodeHandleKey } from '@/constants';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasHandleMainOutput);
|
||||
|
||||
describe('CanvasHandleMainOutput', () => {
|
||||
it('should render correctly', async () => {
|
||||
const label = 'Test Label';
|
||||
const { container, getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
[`${CanvasNodeHandleKey}`]: { label: ref(label) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.querySelector('.canvas-node-handle-main-output')).toBeInTheDocument();
|
||||
expect(getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, useCssModule } from 'vue';
|
||||
import { CanvasNodeHandleKey } from '@/constants';
|
||||
|
||||
const handle = inject(CanvasNodeHandleKey);
|
||||
|
||||
// const group = svg.node('g');
|
||||
// const containerBorder = svg.node('rect', {
|
||||
// rx: 3,
|
||||
// 'stroke-width': 2,
|
||||
// fillOpacity: 0,
|
||||
// height: ep.params.dimensions - 2,
|
||||
// width: ep.params.dimensions - 2,
|
||||
// y: 1,
|
||||
// x: 1,
|
||||
// });
|
||||
// const plusPath = svg.node('path', {
|
||||
// d: 'm16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z',
|
||||
// });
|
||||
// if (ep.params.size !== 'medium') {
|
||||
// ep.addClass(ep.params.size);
|
||||
// }
|
||||
// group.appendChild(containerBorder);
|
||||
// group.appendChild(plusPath);
|
||||
//
|
||||
// ep.setupOverlays();
|
||||
// ep.setVisible(false);
|
||||
// return group;
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
const label = computed(() => handle?.label.value ?? '');
|
||||
</script>
|
||||
<template>
|
||||
<div :class="['canvas-node-handle-main-output', $style.handle]">
|
||||
<div :class="$style.label">{{ label }}</div>
|
||||
<div :class="$style.circle" />
|
||||
<!-- @TODO Determine whether handle is connected and find a way to make it work without pointer-events: none -->
|
||||
<!-- <svg :class="$style.plus" viewBox="0 0 70 24">-->
|
||||
<!-- <line x1="0" y1="12" x2="46" y2="12" stroke="var(--color-foreground-xdark)" />-->
|
||||
<!-- <rect-->
|
||||
<!-- x="46"-->
|
||||
<!-- y="2"-->
|
||||
<!-- width="20"-->
|
||||
<!-- height="20"-->
|
||||
<!-- stroke="var(--color-foreground-xdark)"-->
|
||||
<!-- stroke-width="2"-->
|
||||
<!-- rx="4"-->
|
||||
<!-- fill="#ffffff"-->
|
||||
<!-- />-->
|
||||
<!-- <g transform="translate(44, 0)">-->
|
||||
<!-- <path-->
|
||||
<!-- fill="var(--color-foreground-xdark)"-->
|
||||
<!-- d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"-->
|
||||
<!-- ></path>-->
|
||||
<!-- </g>-->
|
||||
<!-- </svg>-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.handle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 100%;
|
||||
background: var(--color-foreground-xdark);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.plus {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translate(0, -50%);
|
||||
width: 70px;
|
||||
height: 24px;
|
||||
|
||||
:global(.vue-flow__handle.connecting) & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 20px;
|
||||
transform: translate(0, -50%);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-foreground-xdark);
|
||||
background: var(--color-background-light);
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
import CanvasHandleNonMain from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMain.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { CanvasNodeHandleKey } from '@/constants';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasHandleNonMain);
|
||||
|
||||
describe('CanvasHandleNonMain', () => {
|
||||
it('should render correctly', async () => {
|
||||
const label = 'Test Label';
|
||||
const { container, getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
[`${CanvasNodeHandleKey}`]: { label: ref(label) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.querySelector('.canvas-node-handle-non-main')).toBeInTheDocument();
|
||||
expect(getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, useCssModule } from 'vue';
|
||||
import { CanvasNodeHandleKey } from '@/constants';
|
||||
|
||||
const handle = inject(CanvasNodeHandleKey);
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
const label = computed(() => handle?.label.value ?? '');
|
||||
</script>
|
||||
<template>
|
||||
<div :class="['canvas-node-handle-non-main', $style.handle]">
|
||||
<div :class="$style.diamond" />
|
||||
<div :class="$style.label">{{ label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.handle {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.diamond {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: rotate(45deg);
|
||||
background: hsl(
|
||||
var(--node-type-supplemental-color-h) var(--node-type-supplemental-color-s)
|
||||
var(--node-type-supplemental-color-l)
|
||||
);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-foreground-xdark);
|
||||
background: var(--color-background-light);
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
import CanvasNode from '@/components/canvas/elements/nodes/CanvasNode.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { createCanvasNodeProps } from '@/__tests__/data';
|
||||
|
||||
vi.mock('@/stores/nodeTypes.store', () => ({
|
||||
useNodeTypesStore: vi.fn(() => ({
|
||||
getNodeType: vi.fn(() => ({
|
||||
name: 'test',
|
||||
description: 'Test Node Description',
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
beforeEach(() => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
renderComponent = createComponentRenderer(CanvasNode, { pinia });
|
||||
});
|
||||
|
||||
describe('CanvasNode', () => {
|
||||
it('should render node correctly', async () => {
|
||||
const { getByTestId, getByText } = renderComponent({
|
||||
props: {
|
||||
...createCanvasNodeProps(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('Test Node')).toBeInTheDocument();
|
||||
expect(getByTestId('canvas-node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('classes', () => {
|
||||
it('should apply selected class when node is selected', async () => {
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
...createCanvasNodeProps({ selected: true }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('Test Node').closest('.node')).toHaveClass('selected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handles', () => {
|
||||
it('should render correct number of input and output handles', async () => {
|
||||
const { getAllByTestId } = renderComponent({
|
||||
props: {
|
||||
...createCanvasNodeProps({
|
||||
data: {
|
||||
inputs: [
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.Main },
|
||||
],
|
||||
outputs: [{ type: NodeConnectionType.Main }, { type: NodeConnectionType.Main }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
HandleRenderer: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const inputHandles = getAllByTestId('canvas-node-input-handle');
|
||||
const outputHandles = getAllByTestId('canvas-node-output-handle');
|
||||
|
||||
expect(inputHandles.length).toBe(3);
|
||||
expect(outputHandles.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toolbar', () => {
|
||||
it('should render toolbar when node is hovered', async () => {
|
||||
const { getByTestId, container } = renderComponent({
|
||||
props: {
|
||||
...createCanvasNodeProps(),
|
||||
},
|
||||
});
|
||||
|
||||
const node = getByTestId('canvas-node');
|
||||
await fireEvent.mouseOver(node);
|
||||
|
||||
expect(getByTestId('canvas-node-toolbar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts" setup>
|
||||
import { Position } from '@vue-flow/core';
|
||||
import { computed, provide, toRef } from 'vue';
|
||||
import type {
|
||||
CanvasElementData,
|
||||
CanvasConnectionPort,
|
||||
CanvasElementPortWithPosition,
|
||||
} from '@/types';
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
|
||||
import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue';
|
||||
import HandleRenderer from '@/components/canvas/elements/handles/HandleRenderer.vue';
|
||||
import { useNodeConnections } from '@/composables/useNodeConnections';
|
||||
import { CanvasNodeKey } from '@/constants';
|
||||
import type { NodeProps } from '@vue-flow/core';
|
||||
|
||||
const props = defineProps<NodeProps<CanvasElementData>>();
|
||||
|
||||
const inputs = computed(() => props.data.inputs);
|
||||
const outputs = computed(() => props.data.outputs);
|
||||
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
|
||||
const { mainInputs, nonMainInputs, mainOutputs, nonMainOutputs } = useNodeConnections({
|
||||
inputs,
|
||||
outputs,
|
||||
});
|
||||
|
||||
const nodeType = computed(() => {
|
||||
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
|
||||
});
|
||||
|
||||
/**
|
||||
* Inputs
|
||||
*/
|
||||
|
||||
const inputsWithPosition = computed(() => {
|
||||
return [
|
||||
...mainInputs.value.map(mapEndpointWithPosition(Position.Left, 'top')),
|
||||
...nonMainInputs.value.map(mapEndpointWithPosition(Position.Bottom, 'left')),
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* Outputs
|
||||
*/
|
||||
|
||||
const outputsWithPosition = computed(() => {
|
||||
return [
|
||||
...mainOutputs.value.map(mapEndpointWithPosition(Position.Right, 'top')),
|
||||
...nonMainOutputs.value.map(mapEndpointWithPosition(Position.Top, 'left')),
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* Endpoints
|
||||
*/
|
||||
|
||||
const mapEndpointWithPosition =
|
||||
(position: Position, offsetAxis: 'top' | 'left') =>
|
||||
(
|
||||
endpoint: CanvasConnectionPort,
|
||||
index: number,
|
||||
endpoints: CanvasConnectionPort[],
|
||||
): CanvasElementPortWithPosition => {
|
||||
return {
|
||||
...endpoint,
|
||||
position,
|
||||
offset: {
|
||||
[offsetAxis]: `${(100 / (endpoints.length + 1)) * (index + 1)}%`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Provide
|
||||
*/
|
||||
|
||||
const id = toRef(props, 'id');
|
||||
const data = toRef(props, 'data');
|
||||
const label = toRef(props, 'label');
|
||||
const selected = toRef(props, 'selected');
|
||||
|
||||
provide(CanvasNodeKey, {
|
||||
id,
|
||||
data,
|
||||
label,
|
||||
selected,
|
||||
nodeType,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.canvasNode" data-test-id="canvas-node">
|
||||
<template v-for="source in outputsWithPosition" :key="`${source.type}/${source.index}`">
|
||||
<HandleRenderer
|
||||
mode="output"
|
||||
data-test-id="canvas-node-output-handle"
|
||||
:type="source.type"
|
||||
:label="source.label"
|
||||
:index="source.index"
|
||||
:position="source.position"
|
||||
:offset="source.offset"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-for="target in inputsWithPosition" :key="`${target.type}/${target.index}`">
|
||||
<HandleRenderer
|
||||
mode="input"
|
||||
data-test-id="canvas-node-input-handle"
|
||||
:type="target.type"
|
||||
:label="target.label"
|
||||
:index="target.index"
|
||||
:position="target.position"
|
||||
:offset="target.offset"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<CanvasNodeToolbar
|
||||
v-if="nodeType"
|
||||
data-test-id="canvas-node-toolbar"
|
||||
:class="$style.canvasNodeToolbar"
|
||||
/>
|
||||
|
||||
<CanvasNodeRenderer v-if="nodeType">
|
||||
<NodeIcon :node-type="nodeType" :size="40" :shrink="false" />
|
||||
<!-- :color-default="iconColorDefault"-->
|
||||
<!-- :disabled="data.disabled"-->
|
||||
</CanvasNodeRenderer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.canvasNode {
|
||||
&:hover {
|
||||
.canvasNodeToolbar {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.canvasNodeToolbar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createCanvasNodeProvide } from '@/__tests__/data';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasNodeRenderer);
|
||||
|
||||
describe('CanvasNodeRenderer', () => {
|
||||
it('should render default node correctly', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('canvas-node-default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render configuration node correctly', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide({
|
||||
data: {
|
||||
renderType: 'configuration',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('canvas-node-configuration')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render configurable node correctly', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide({
|
||||
data: {
|
||||
renderType: 'configurable',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('canvas-node-configurable')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { h, inject } from 'vue';
|
||||
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
|
||||
import CanvasNodeConfiguration from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.vue';
|
||||
import CanvasNodeConfigurable from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue';
|
||||
import { CanvasNodeKey } from '@/constants';
|
||||
|
||||
const node = inject(CanvasNodeKey);
|
||||
|
||||
const slots = defineSlots<{
|
||||
default?: () => unknown;
|
||||
}>();
|
||||
|
||||
const Render = () => {
|
||||
let Component;
|
||||
switch (node?.data.value.renderType) {
|
||||
case 'configurable':
|
||||
Component = CanvasNodeConfigurable;
|
||||
break;
|
||||
|
||||
case 'configuration':
|
||||
Component = CanvasNodeConfiguration;
|
||||
break;
|
||||
|
||||
case 'trigger':
|
||||
Component = CanvasNodeDefault;
|
||||
break;
|
||||
|
||||
default:
|
||||
Component = CanvasNodeDefault;
|
||||
}
|
||||
|
||||
return h(Component, slots.default);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Render />
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createCanvasNodeProvide } from '@/__tests__/data';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasNodeToolbar);
|
||||
|
||||
describe('CanvasNodeToolbar', () => {
|
||||
it('should render execute node button when renderType is not configuration', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('execute-node-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render execute node button when renderType is configuration', async () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide({
|
||||
data: {
|
||||
renderType: 'configuration',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('execute-node-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call executeNode function when execute node button is clicked', async () => {
|
||||
const executeNode = vi.fn();
|
||||
const { getByTestId } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
},
|
||||
mocks: {
|
||||
executeNode,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('execute-node-button'));
|
||||
|
||||
expect(executeNode).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call toggleDisableNode function when disable node button is clicked', async () => {
|
||||
const toggleDisableNode = vi.fn();
|
||||
const { getByTestId } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
},
|
||||
mocks: {
|
||||
toggleDisableNode,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('disable-node-button'));
|
||||
|
||||
expect(toggleDisableNode).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call deleteNode function when delete node button is clicked', async () => {
|
||||
const deleteNode = vi.fn();
|
||||
const { getByTestId } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
},
|
||||
mocks: {
|
||||
deleteNode,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('delete-node-button'));
|
||||
|
||||
expect(deleteNode).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call openContextMenu function when overflow node button is clicked', async () => {
|
||||
const openContextMenu = vi.fn();
|
||||
const { getByTestId } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
},
|
||||
mocks: {
|
||||
openContextMenu,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('overflow-node-button'));
|
||||
|
||||
expect(openContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, useCssModule } from 'vue';
|
||||
import { CanvasNodeKey } from '@/constants';
|
||||
|
||||
const node = inject(CanvasNodeKey);
|
||||
|
||||
const data = computed(() => node?.data.value);
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
// @TODO
|
||||
const workflowRunning = false;
|
||||
|
||||
// @TODO
|
||||
const nodeDisabledTitle = 'Test';
|
||||
|
||||
// @TODO
|
||||
function executeNode() {}
|
||||
|
||||
// @TODO
|
||||
function toggleDisableNode() {}
|
||||
|
||||
// @TODO
|
||||
function deleteNode() {}
|
||||
|
||||
// @TODO
|
||||
function openContextMenu(e: MouseEvent, type: string) {}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.canvasNodeToolbar">
|
||||
<div :class="$style.canvasNodeToolbarItems">
|
||||
<N8nIconButton
|
||||
v-if="data?.renderType !== 'configuration'"
|
||||
data-test-id="execute-node-button"
|
||||
type="tertiary"
|
||||
text
|
||||
size="small"
|
||||
icon="play"
|
||||
:disabled="workflowRunning"
|
||||
:title="$locale.baseText('node.testStep')"
|
||||
@click="executeNode"
|
||||
/>
|
||||
<N8nIconButton
|
||||
data-test-id="disable-node-button"
|
||||
type="tertiary"
|
||||
text
|
||||
size="small"
|
||||
icon="power-off"
|
||||
:title="nodeDisabledTitle"
|
||||
@click="toggleDisableNode"
|
||||
/>
|
||||
<N8nIconButton
|
||||
data-test-id="delete-node-button"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
text
|
||||
icon="trash"
|
||||
:title="$locale.baseText('node.delete')"
|
||||
@click="deleteNode"
|
||||
/>
|
||||
<N8nIconButton
|
||||
data-test-id="overflow-node-button"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
text
|
||||
icon="ellipsis-h"
|
||||
@click="(e: MouseEvent) => openContextMenu(e, 'node-button')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.canvasNodeToolbar {
|
||||
padding-bottom: var(--spacing-3xs);
|
||||
}
|
||||
|
||||
.canvasNodeToolbarItems {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
import CanvasNodeConfigurable from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import { createCanvasNodeProvide } from '@/__tests__/data';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasNodeConfigurable);
|
||||
|
||||
describe('CanvasNodeConfigurable', () => {
|
||||
it('should render node correctly', () => {
|
||||
const { getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('Test Node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('selected', () => {
|
||||
it('should apply selected class when node is selected', () => {
|
||||
const { getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide({
|
||||
selected: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getByText('Test Node').closest('.node')).toHaveClass('selected');
|
||||
});
|
||||
|
||||
it('should not apply selected class when node is not selected', () => {
|
||||
const { getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getByText('Test Node').closest('.node')).not.toHaveClass('selected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inputs', () => {
|
||||
it('should adjust width css variable based on the number of non-main inputs', () => {
|
||||
const { getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide({
|
||||
data: {
|
||||
inputs: [
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.AiTool },
|
||||
{ type: NodeConnectionType.AiDocument, required: true },
|
||||
{ type: NodeConnectionType.AiMemory, required: true },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const nodeElement = getByText('Test Node').closest('.node');
|
||||
expect(nodeElement).toHaveStyle({ '--configurable-node-input-count': '3' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, useCssModule } from 'vue';
|
||||
import { CanvasNodeKey, NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
|
||||
import { useNodeConnections } from '@/composables/useNodeConnections';
|
||||
|
||||
const node = inject(CanvasNodeKey);
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
const label = computed(() => node?.label.value ?? '');
|
||||
const inputs = computed(() => node?.data.value.inputs ?? []);
|
||||
const outputs = computed(() => node?.data.value.outputs ?? []);
|
||||
|
||||
const { nonMainInputs, requiredNonMainInputs } = useNodeConnections({
|
||||
inputs,
|
||||
outputs,
|
||||
});
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
[$style.node]: true,
|
||||
[$style.selected]: node?.selected.value,
|
||||
};
|
||||
});
|
||||
|
||||
const styles = computed(() => {
|
||||
const stylesObject: {
|
||||
[key: string]: string | number;
|
||||
} = {};
|
||||
|
||||
if (requiredNonMainInputs.value.length > 0) {
|
||||
let spacerCount = 0;
|
||||
if (NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS) {
|
||||
const requiredNonMainInputsCount = requiredNonMainInputs.value.length;
|
||||
const optionalNonMainInputsCount = nonMainInputs.value.length - requiredNonMainInputsCount;
|
||||
spacerCount = requiredNonMainInputsCount > 0 && optionalNonMainInputsCount > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
stylesObject['--configurable-node-input-count'] = nonMainInputs.value.length + spacerCount;
|
||||
}
|
||||
|
||||
return stylesObject;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classes" :style="styles" data-test-id="canvas-node-configurable">
|
||||
<slot />
|
||||
<div :class="$style.label">{{ label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.node {
|
||||
--configurable-node-min-input-count: 4;
|
||||
--configurable-node-input-width: 65px;
|
||||
|
||||
width: calc(
|
||||
max(var(--configurable-node-input-count, 5), var(--configurable-node-min-input-count)) *
|
||||
var(--configurable-node-input-width)
|
||||
);
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--canvas-node--background, var(--color-canvas-node-background));
|
||||
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark));
|
||||
border-radius: var(--border-radius-large);
|
||||
}
|
||||
|
||||
.label {
|
||||
top: 100%;
|
||||
font-size: var(--font-size-m);
|
||||
text-align: center;
|
||||
margin-left: var(--spacing-s);
|
||||
max-width: calc(
|
||||
var(--node-width) - var(--configurable-node-icon-offset) - var(--configurable-node-icon-size) -
|
||||
2 * var(--spacing-s)
|
||||
);
|
||||
}
|
||||
|
||||
.selected {
|
||||
box-shadow: 0 0 0 4px var(--color-canvas-selected);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
import CanvasNodeConfiguration from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createCanvasNodeProvide } from '@/__tests__/data';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasNodeConfiguration);
|
||||
|
||||
describe('CanvasNodeConfiguration', () => {
|
||||
it('should render node correctly', () => {
|
||||
const { getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('Test Node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('selected', () => {
|
||||
it('should apply selected class when node is selected', () => {
|
||||
const { getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide({ selected: true }),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getByText('Test Node').closest('.node')).toHaveClass('selected');
|
||||
});
|
||||
|
||||
it('should not apply selected class when node is not selected', () => {
|
||||
const { getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getByText('Test Node').closest('.node')).not.toHaveClass('selected');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, useCssModule } from 'vue';
|
||||
import { CanvasNodeKey } from '@/constants';
|
||||
|
||||
const node = inject(CanvasNodeKey);
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
const label = computed(() => node?.label.value ?? '');
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
[$style.node]: true,
|
||||
[$style.selected]: node?.selected.value,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classes" data-test-id="canvas-node-configuration">
|
||||
<slot />
|
||||
<div v-if="label" :class="$style.label">{{ label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.node {
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--canvas-node--background, var(--node-type-supplemental-background));
|
||||
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-dark));
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.selected {
|
||||
box-shadow: 0 0 0 4px var(--color-canvas-selected);
|
||||
}
|
||||
|
||||
.label {
|
||||
top: 100%;
|
||||
position: absolute;
|
||||
font-size: var(--font-size-m);
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
margin-top: var(--spacing-2xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import { createCanvasNodeProvide } from '@/__tests__/data';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasNodeDefault);
|
||||
|
||||
describe('CanvasNodeDefault', () => {
|
||||
it('should render node correctly', () => {
|
||||
const { getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('Test Node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('outputs', () => {
|
||||
it('should adjust height css variable based on the number of outputs (1 output)', () => {
|
||||
const { getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide({
|
||||
data: {
|
||||
outputs: [{ type: NodeConnectionType.Main }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const nodeElement = getByText('Test Node').closest('.node');
|
||||
expect(nodeElement).toHaveStyle({ '--node-main-output-count': '1' }); // height calculation based on the number of outputs
|
||||
});
|
||||
|
||||
it('should adjust height css variable based on the number of outputs (multiple outputs)', () => {
|
||||
const { getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide({
|
||||
data: {
|
||||
outputs: [
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.Main },
|
||||
{ type: NodeConnectionType.Main },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const nodeElement = getByText('Test Node').closest('.node');
|
||||
expect(nodeElement).toHaveStyle({ '--node-main-output-count': '3' }); // height calculation based on the number of outputs
|
||||
});
|
||||
});
|
||||
|
||||
describe('selected', () => {
|
||||
it('should apply selected class when node is selected', () => {
|
||||
const { getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide({ selected: true }),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getByText('Test Node').closest('.node')).toHaveClass('selected');
|
||||
});
|
||||
|
||||
it('should not apply selected class when node is not selected', () => {
|
||||
const { getByText } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getByText('Test Node').closest('.node')).not.toHaveClass('selected');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, useCssModule } from 'vue';
|
||||
import { useNodeConnections } from '@/composables/useNodeConnections';
|
||||
import { CanvasNodeKey } from '@/constants';
|
||||
|
||||
const node = inject(CanvasNodeKey);
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
const label = computed(() => node?.label.value ?? '');
|
||||
const inputs = computed(() => node?.data.value.inputs ?? []);
|
||||
const outputs = computed(() => node?.data.value.outputs ?? []);
|
||||
|
||||
const { mainOutputs } = useNodeConnections({
|
||||
inputs,
|
||||
outputs,
|
||||
});
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
[$style.node]: true,
|
||||
[$style.selected]: node?.selected.value,
|
||||
};
|
||||
});
|
||||
|
||||
const styles = computed(() => {
|
||||
return {
|
||||
'--node-main-output-count': mainOutputs.value.length,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.node {
|
||||
height: calc(100px + max(0, var(--node-main-output-count, 1) - 4) * 50px);
|
||||
width: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--canvas-node--background, var(--color-canvas-node-background));
|
||||
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark));
|
||||
border-radius: var(--border-radius-large);
|
||||
}
|
||||
|
||||
.label {
|
||||
top: 100%;
|
||||
position: absolute;
|
||||
font-size: var(--font-size-m);
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
margin-top: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.selected {
|
||||
box-shadow: 0 0 0 4px var(--color-canvas-selected);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user