diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 34dd4b37db..c3a123b8a1 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -45,6 +45,11 @@ "@n8n/chat": "workspace:*", "@n8n/codemirror-lang-sql": "^1.0.2", "@n8n/permissions": "workspace:*", + "@vue-flow/background": "^1.3.0", + "@vue-flow/controls": "^1.1.1", + "@vue-flow/core": "^1.33.5", + "@vue-flow/minimap": "^1.4.0", + "@vue-flow/node-toolbar": "^1.1.0", "@vueuse/components": "^10.5.0", "@vueuse/core": "^10.5.0", "axios": "1.6.7", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index e5764af8c7..c30be6b89d 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1317,6 +1317,7 @@ export interface UIState { pendingNotificationsForViews: { [key in VIEWS]?: NotificationOptions[]; }; + isCreateNodeActive: boolean; } export type IFakeDoor = { @@ -1898,7 +1899,7 @@ export type AddedNodesAndConnections = { export type ToggleNodeCreatorOptions = { createNodeActive: boolean; source?: NodeCreatorOpenSource; - nodeCreatorView?: string; + nodeCreatorView?: NodeFilterType; }; export type AppliedThemeOption = 'light' | 'dark'; diff --git a/packages/editor-ui/src/__tests__/data/canvas.ts b/packages/editor-ui/src/__tests__/data/canvas.ts new file mode 100644 index 0000000000..7ff598996c --- /dev/null +++ b/packages/editor-ui/src/__tests__/data/canvas.ts @@ -0,0 +1,87 @@ +import { CanvasNodeKey } from '@/constants'; +import { ref } from 'vue'; +import type { CanvasElement, CanvasElementData } from '@/types'; + +export function createCanvasNodeData({ + id = 'node', + type = 'test', + typeVersion = 1, + inputs = [], + outputs = [], + renderType = 'default', +}: Partial = {}): CanvasElementData { + return { + id, + type, + typeVersion, + inputs, + outputs, + renderType, + }; +} + +export function createCanvasNodeElement({ + id = '1', + type = 'node', + label = 'Node', + position = { x: 100, y: 100 }, + data, +}: Partial< + Omit & { data: Partial } +> = {}): CanvasElement { + return { + id, + type, + label, + position, + data: createCanvasNodeData({ id, type, ...data }), + }; +} + +export function createCanvasNodeProps({ + id = 'node', + label = 'Test Node', + selected = false, + data = {}, +} = {}) { + return { + id, + label, + selected, + data: createCanvasNodeData(data), + }; +} + +export function createCanvasNodeProvide({ + id = 'node', + label = 'Test Node', + selected = false, + data = {}, +} = {}) { + const props = createCanvasNodeProps({ id, label, selected, data }); + return { + [`${CanvasNodeKey}`]: { + id: ref(props.id), + label: ref(props.label), + selected: ref(props.selected), + data: ref(props.data), + }, + }; +} + +export function createCanvasConnection( + nodeA: CanvasElement, + nodeB: CanvasElement, + { sourceIndex = 0, targetIndex = 0 } = {}, +) { + const nodeAOutput = nodeA.data?.outputs[sourceIndex]; + const nodeBInput = nodeA.data?.inputs[targetIndex]; + + return { + id: `${nodeA.id}-${nodeB.id}`, + source: nodeA.id, + target: nodeB.id, + ...(nodeAOutput ? { sourceHandle: `outputs/${nodeAOutput.type}/${nodeAOutput.index}` } : {}), + ...(nodeBInput ? { targetHandle: `inputs/${nodeBInput.type}/${nodeBInput.index}` } : {}), + }; +} diff --git a/packages/editor-ui/src/__tests__/data/index.ts b/packages/editor-ui/src/__tests__/data/index.ts new file mode 100644 index 0000000000..baba675788 --- /dev/null +++ b/packages/editor-ui/src/__tests__/data/index.ts @@ -0,0 +1 @@ +export * from './canvas'; diff --git a/packages/editor-ui/src/__tests__/defaults.ts b/packages/editor-ui/src/__tests__/defaults.ts index 98ce362c7e..7c034604f6 100644 --- a/packages/editor-ui/src/__tests__/defaults.ts +++ b/packages/editor-ui/src/__tests__/defaults.ts @@ -1,5 +1,10 @@ import type { INodeTypeData, INodeTypeDescription, IN8nUISettings } from 'n8n-workflow'; -import { AGENT_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from '@/constants'; +import { + AGENT_NODE_TYPE, + SET_NODE_TYPE, + CHAT_TRIGGER_NODE_TYPE, + MANUAL_TRIGGER_NODE_TYPE, +} from '@/constants'; import nodeTypesJson from '../../../nodes-base/dist/types/nodes.json'; import aiNodeTypesJson from '../../../@n8n/nodes-langchain/dist/types/nodes.json'; @@ -16,6 +21,12 @@ export const testingNodeTypes: INodeTypeData = { description: findNodeWithName(MANUAL_TRIGGER_NODE_TYPE), }, }, + [SET_NODE_TYPE]: { + sourcePath: '', + type: { + description: findNodeWithName(SET_NODE_TYPE), + }, + }, [CHAT_TRIGGER_NODE_TYPE]: { sourcePath: '', type: { @@ -32,6 +43,7 @@ export const testingNodeTypes: INodeTypeData = { export const defaultMockNodeTypes: INodeTypeData = { [MANUAL_TRIGGER_NODE_TYPE]: testingNodeTypes[MANUAL_TRIGGER_NODE_TYPE], + [SET_NODE_TYPE]: testingNodeTypes[SET_NODE_TYPE], }; export function mockNodeTypesToArray(nodeTypes: INodeTypeData): INodeTypeDescription[] { diff --git a/packages/editor-ui/src/__tests__/mocks.ts b/packages/editor-ui/src/__tests__/mocks.ts index e934f9518f..130552c419 100644 --- a/packages/editor-ui/src/__tests__/mocks.ts +++ b/packages/editor-ui/src/__tests__/mocks.ts @@ -17,22 +17,21 @@ import type { ProjectSharingData } from '@/features/projects/projects.types'; import type { RouteLocationNormalized } from 'vue-router'; export function createTestNodeTypes(data: INodeTypeData = {}): INodeTypes { - const getResolvedKey = (key: string) => { - const resolvedKeyParts = key.split(/[\/.]/); - return resolvedKeyParts[resolvedKeyParts.length - 1]; - }; - const nodeTypes = { ...defaultMockNodeTypes, ...Object.keys(data).reduce((acc, key) => { - acc[getResolvedKey(key)] = data[key]; + acc[key] = data[key]; return acc; }, {}), }; + function getKnownTypes(): IDataObject { + return {}; + } + function getByName(nodeType: string): INodeType | IVersionedNodeType { - return nodeTypes[getResolvedKey(nodeType)].type; + return nodeTypes[nodeType].type; } function getByNameAndVersion(nodeType: string, version?: number): INodeType { @@ -40,6 +39,7 @@ export function createTestNodeTypes(data: INodeTypeData = {}): INodeTypes { } return { + getKnownTypes, getByName, getByNameAndVersion, }; diff --git a/packages/editor-ui/src/__tests__/render.ts b/packages/editor-ui/src/__tests__/render.ts index 6561dae4bc..e46b615993 100644 --- a/packages/editor-ui/src/__tests__/render.ts +++ b/packages/editor-ui/src/__tests__/render.ts @@ -19,7 +19,8 @@ export type RenderOptions = Parameters[1] & { const TelemetryPlugin: Plugin<{}> = { install(app) { app.config.globalProperties.$telemetry = { - track(event: string, properties?: object) {}, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + track(..._: unknown[]) {}, } as Telemetry; }, }; diff --git a/packages/editor-ui/src/__tests__/utils.ts b/packages/editor-ui/src/__tests__/utils.ts index e7d6340f5f..9ae18517fe 100644 --- a/packages/editor-ui/src/__tests__/utils.ts +++ b/packages/editor-ui/src/__tests__/utils.ts @@ -32,6 +32,7 @@ export const retry = async ( export const waitAllPromises = async () => await new Promise((resolve) => setTimeout(resolve)); export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = { + initialized: true, settings: defaultSettings, promptsData: { message: '', @@ -62,14 +63,13 @@ export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = { loginLabel: '', loginEnabled: false, }, + mfa: { + enabled: false, + }, onboardingCallPromptEnabled: false, saveDataErrorExecution: 'all', saveDataSuccessExecution: 'all', saveManualExecutions: false, - initialized: false, - mfa: { - enabled: false, - }, }; export const getDropdownItems = async (dropdownTriggerParent: HTMLElement) => { diff --git a/packages/editor-ui/src/components/canvas/Canvas.spec.ts b/packages/editor-ui/src/components/canvas/Canvas.spec.ts new file mode 100644 index 0000000000..8c98f03f88 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/Canvas.spec.ts @@ -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; +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 }]]); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/Canvas.vue b/packages/editor-ui/src/components/canvas/Canvas.vue new file mode 100644 index 0000000000..5fac47bafe --- /dev/null +++ b/packages/editor-ui/src/components/canvas/Canvas.vue @@ -0,0 +1,117 @@ + + + + + + + diff --git a/packages/editor-ui/src/components/canvas/WorkflowCanvas.vue b/packages/editor-ui/src/components/canvas/WorkflowCanvas.vue new file mode 100644 index 0000000000..f5dad78e34 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/WorkflowCanvas.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/buttons/CanvasExecuteWorkflowButton.vue b/packages/editor-ui/src/components/canvas/elements/buttons/CanvasExecuteWorkflowButton.vue new file mode 100644 index 0000000000..a595c0febd --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/buttons/CanvasExecuteWorkflowButton.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue new file mode 100644 index 0000000000..5a4c0046c4 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue @@ -0,0 +1,40 @@ + + + diff --git a/packages/editor-ui/src/components/canvas/elements/handles/HandleRenderer.spec.ts b/packages/editor-ui/src/components/canvas/elements/handles/HandleRenderer.spec.ts new file mode 100644 index 0000000000..75d4889f8d --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/HandleRenderer.spec.ts @@ -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: '
', +}; + +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(); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/handles/HandleRenderer.vue b/packages/editor-ui/src/components/canvas/elements/handles/HandleRenderer.vue new file mode 100644 index 0000000000..0f8968a311 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/HandleRenderer.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainInput.spec.ts b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainInput.spec.ts new file mode 100644 index 0000000000..1cc06fc74c --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainInput.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue new file mode 100644 index 0000000000..dd9bdec44d --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue @@ -0,0 +1,39 @@ + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.spec.ts b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.spec.ts new file mode 100644 index 0000000000..c324e3a2e7 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue new file mode 100644 index 0000000000..c0d016cbf1 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue @@ -0,0 +1,101 @@ + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMain.spec.ts b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMain.spec.ts new file mode 100644 index 0000000000..7544c7587a --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMain.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMain.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMain.vue new file mode 100644 index 0000000000..32e3ee9c98 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMain.vue @@ -0,0 +1,49 @@ + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.spec.ts b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.spec.ts new file mode 100644 index 0000000000..35fd858470 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.spec.ts @@ -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; +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(); + }); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue new file mode 100644 index 0000000000..a9e635f253 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.spec.ts b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.spec.ts new file mode 100644 index 0000000000..09a304f35c --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.vue b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.vue new file mode 100644 index 0000000000..65b13bf891 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.spec.ts b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.spec.ts new file mode 100644 index 0000000000..2b6432df34 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.vue b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.vue new file mode 100644 index 0000000000..b86aadedd0 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.spec.ts b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.spec.ts new file mode 100644 index 0000000000..c3c233b9e5 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.spec.ts @@ -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' }); + }); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue new file mode 100644 index 0000000000..6a8a5d4fac --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.spec.ts b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.spec.ts new file mode 100644 index 0000000000..378c87b96b --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.spec.ts @@ -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'); + }); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.vue new file mode 100644 index 0000000000..f98d4ff747 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.spec.ts b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.spec.ts new file mode 100644 index 0000000000..37e2415b5b --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.spec.ts @@ -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'); + }); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue new file mode 100644 index 0000000000..0fee29e777 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/editor-ui/src/composables/useCanvasMapping.spec.ts b/packages/editor-ui/src/composables/useCanvasMapping.spec.ts new file mode 100644 index 0000000000..18ab57a19e --- /dev/null +++ b/packages/editor-ui/src/composables/useCanvasMapping.spec.ts @@ -0,0 +1,213 @@ +import type { Ref } from 'vue'; +import { ref } from 'vue'; +import { useCanvasMapping } from '@/composables/useCanvasMapping'; +import { createTestNode, createTestWorkflow, createTestWorkflowObject } from '@/__tests__/mocks'; +import type { IConnections, Workflow } from 'n8n-workflow'; +import { createPinia, setActivePinia } from 'pinia'; +import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants'; +import { NodeConnectionType } from 'n8n-workflow'; + +vi.mock('@/stores/nodeTypes.store', () => ({ + useNodeTypesStore: vi.fn(() => ({ + getNodeType: vi.fn(() => ({ + name: 'test', + description: 'Test Node Description', + })), + isTriggerNode: vi.fn(), + isConfigNode: vi.fn(), + isConfigurableNode: vi.fn(), + })), +})); + +beforeEach(() => { + const pinia = createPinia(); + setActivePinia(pinia); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('useCanvasMapping', () => { + it('should initialize with default props', () => { + const workflow = createTestWorkflow({ + id: '1', + name: 'Test Workflow', + nodes: [], + connections: {}, + }); + const workflowObject = createTestWorkflowObject(workflow); + + const { elements, connections } = useCanvasMapping({ + workflow: ref(workflow), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(elements.value).toEqual([]); + expect(connections.value).toEqual([]); + }); + + describe('elements', () => { + it('should map nodes to canvas elements', () => { + const node = createTestNode({ + name: 'Node', + type: MANUAL_TRIGGER_NODE_TYPE, + }); + const workflow = createTestWorkflow({ + name: 'Test Workflow', + nodes: [node], + connections: {}, + }); + const workflowObject = createTestWorkflowObject(workflow); + + const { elements } = useCanvasMapping({ + workflow: ref(workflow), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(elements.value).toEqual([ + { + id: node.id, + label: node.name, + type: 'canvas-node', + position: { x: 0, y: 0 }, + data: { + id: node.id, + type: node.type, + typeVersion: 1, + inputs: [], + outputs: [], + renderType: 'default', + }, + }, + ]); + }); + }); + + describe('connections', () => { + it('should map connections to canvas connections', () => { + const nodeA = createTestNode({ + name: 'Node A', + type: MANUAL_TRIGGER_NODE_TYPE, + }); + const nodeB = createTestNode({ + name: 'Node B', + type: SET_NODE_TYPE, + }); + const workflow = createTestWorkflow({ + name: 'Test Workflow', + nodes: [nodeA, nodeB], + connections: { + [nodeA.name]: { + [NodeConnectionType.Main]: [ + [{ node: nodeB.name, type: NodeConnectionType.Main, index: 0 }], + ], + }, + } as IConnections, + }); + const workflowObject = createTestWorkflowObject(workflow); + + const { connections } = useCanvasMapping({ + workflow: ref(workflow), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(connections.value).toEqual([ + { + data: { + fromNodeName: nodeA.name, + source: { + index: 0, + type: NodeConnectionType.Main, + }, + target: { + index: 0, + type: NodeConnectionType.Main, + }, + }, + id: `[${nodeA.id}/${NodeConnectionType.Main}/0][${nodeB.id}/${NodeConnectionType.Main}/0]`, + label: '', + source: nodeA.id, + sourceHandle: `outputs/${NodeConnectionType.Main}/0`, + target: nodeB.id, + targetHandle: `inputs/${NodeConnectionType.Main}/0`, + type: 'canvas-edge', + }, + ]); + }); + + it('should map multiple input types to canvas connections', () => { + const nodeA = createTestNode({ + name: 'Node A', + type: MANUAL_TRIGGER_NODE_TYPE, + }); + const nodeB = createTestNode({ + name: 'Node B', + type: SET_NODE_TYPE, + }); + const workflow = createTestWorkflow({ + name: 'Test Workflow', + nodes: [nodeA, nodeB], + connections: { + 'Node A': { + [NodeConnectionType.AiTool]: [ + [{ node: nodeB.name, type: NodeConnectionType.AiTool, index: 0 }], + ], + [NodeConnectionType.AiDocument]: [ + [{ node: nodeB.name, type: NodeConnectionType.AiDocument, index: 1 }], + ], + }, + }, + }); + const workflowObject = createTestWorkflowObject(workflow); + + const { connections } = useCanvasMapping({ + workflow: ref(workflow), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(connections.value).toEqual([ + { + data: { + fromNodeName: nodeA.name, + source: { + index: 0, + type: NodeConnectionType.AiTool, + }, + target: { + index: 0, + type: NodeConnectionType.AiTool, + }, + }, + id: `[${nodeA.id}/${NodeConnectionType.AiTool}/0][${nodeB.id}/${NodeConnectionType.AiTool}/0]`, + label: '', + source: nodeA.id, + sourceHandle: `outputs/${NodeConnectionType.AiTool}/0`, + target: nodeB.id, + targetHandle: `inputs/${NodeConnectionType.AiTool}/0`, + type: 'canvas-edge', + }, + { + data: { + fromNodeName: nodeA.name, + source: { + index: 0, + type: NodeConnectionType.AiDocument, + }, + target: { + index: 1, + type: NodeConnectionType.AiDocument, + }, + }, + id: `[${nodeA.id}/${NodeConnectionType.AiDocument}/0][${nodeB.id}/${NodeConnectionType.AiDocument}/1]`, + label: '', + source: nodeA.id, + sourceHandle: `outputs/${NodeConnectionType.AiDocument}/0`, + target: nodeB.id, + targetHandle: `inputs/${NodeConnectionType.AiDocument}/1`, + type: 'canvas-edge', + }, + ]); + }); + }); +}); diff --git a/packages/editor-ui/src/composables/useCanvasMapping.ts b/packages/editor-ui/src/composables/useCanvasMapping.ts new file mode 100644 index 0000000000..41e6e15a61 --- /dev/null +++ b/packages/editor-ui/src/composables/useCanvasMapping.ts @@ -0,0 +1,150 @@ +import { useI18n } from '@/composables/useI18n'; +import { useNodeTypesStore } from '@/stores/nodeTypes.store'; +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import type { + CanvasConnection, + CanvasConnectionPort, + CanvasElement, + CanvasElementData, +} from '@/types'; +import { + mapLegacyConnectionsToCanvasConnections, + mapLegacyEndpointsToCanvasConnectionPort, +} from '@/utils/canvasUtilsV2'; +import type { Workflow } from 'n8n-workflow'; +import { NodeHelpers } from 'n8n-workflow'; +import type { IWorkflowDb } from '@/Interface'; + +export function useCanvasMapping({ + workflow, + workflowObject, +}: { + workflow: Ref; + workflowObject: Ref; +}) { + const locale = useI18n(); + const nodeTypesStore = useNodeTypesStore(); + + const renderTypeByNodeType = computed( + () => + workflow.value.nodes.reduce>((acc, node) => { + let renderType: CanvasElementData['renderType'] = 'default'; + switch (true) { + case nodeTypesStore.isTriggerNode(node.type): + renderType = 'trigger'; + break; + case nodeTypesStore.isConfigNode(workflowObject.value, node, node.type): + renderType = 'configuration'; + break; + case nodeTypesStore.isConfigurableNode(workflowObject.value, node, node.type): + renderType = 'configurable'; + break; + } + + acc[node.type] = renderType; + return acc; + }, {}) ?? {}, + ); + + const nodeInputsById = computed(() => + workflow.value.nodes.reduce>((acc, node) => { + const nodeTypeDescription = nodeTypesStore.getNodeType(node.type); + const workflowObjectNode = workflowObject.value.getNode(node.name); + + acc[node.id] = + workflowObjectNode && nodeTypeDescription + ? mapLegacyEndpointsToCanvasConnectionPort( + NodeHelpers.getNodeInputs( + workflowObject.value, + workflowObjectNode, + nodeTypeDescription, + ), + ) + : []; + + return acc; + }, {}), + ); + + const nodeOutputsById = computed(() => + workflow.value.nodes.reduce>((acc, node) => { + const nodeTypeDescription = nodeTypesStore.getNodeType(node.type); + const workflowObjectNode = workflowObject.value.getNode(node.name); + + acc[node.id] = + workflowObjectNode && nodeTypeDescription + ? mapLegacyEndpointsToCanvasConnectionPort( + NodeHelpers.getNodeOutputs( + workflowObject.value, + workflowObjectNode, + nodeTypeDescription, + ), + ) + : []; + + return acc; + }, {}), + ); + + const elements = computed(() => [ + ...workflow.value.nodes.map((node) => { + const data: CanvasElementData = { + id: node.id, + type: node.type, + typeVersion: node.typeVersion, + inputs: nodeInputsById.value[node.id] ?? [], + outputs: nodeOutputsById.value[node.id] ?? [], + renderType: renderTypeByNodeType.value[node.type] ?? 'default', + }; + + return { + id: node.id, + label: node.name, + type: 'canvas-node', + position: { x: node.position[0], y: node.position[1] }, + data, + }; + }), + ]); + + const connections = computed(() => { + const mappedConnections = mapLegacyConnectionsToCanvasConnections( + workflow.value.connections ?? [], + workflow.value.nodes ?? [], + ); + + return mappedConnections.map((connection) => { + const type = getConnectionType(connection); + const label = getConnectionLabel(connection); + + return { + ...connection, + type, + label, + }; + }); + }); + + function getConnectionType(_: CanvasConnection): string { + return 'canvas-edge'; + } + + function getConnectionLabel(connection: CanvasConnection): string { + const pinData = workflow.value.pinData?.[connection.data?.fromNodeName ?? '']; + + if (pinData?.length) { + return locale.baseText('ndv.output.items', { + adjustToNumber: pinData.length, + interpolate: { count: String(pinData.length) }, + }); + } + + return ''; + } + + return { + connections, + elements, + }; +} diff --git a/packages/editor-ui/src/composables/useNodeConnections.spec.ts b/packages/editor-ui/src/composables/useNodeConnections.spec.ts new file mode 100644 index 0000000000..5201df4b73 --- /dev/null +++ b/packages/editor-ui/src/composables/useNodeConnections.spec.ts @@ -0,0 +1,88 @@ +import { ref } from 'vue'; +import { NodeConnectionType } from 'n8n-workflow'; +import { useNodeConnections } from '@/composables/useNodeConnections'; +import type { CanvasElementData } from '@/types'; + +describe('useNodeConnections', () => { + describe('mainInputs', () => { + it('should return main inputs when provided with main inputs', () => { + const inputs = ref([ + { type: NodeConnectionType.Main, index: 0 }, + { type: NodeConnectionType.Main, index: 1 }, + { type: NodeConnectionType.Main, index: 2 }, + { type: NodeConnectionType.AiAgent, index: 0 }, + ]); + const outputs = ref([]); + + const { mainInputs } = useNodeConnections({ inputs, outputs }); + + expect(mainInputs.value.length).toBe(3); + expect(mainInputs.value).toEqual(inputs.value.slice(0, 3)); + }); + }); + + describe('nonMainInputs', () => { + it('should return non-main inputs when provided with non-main inputs', () => { + const inputs = ref([ + { type: NodeConnectionType.Main, index: 0 }, + { type: NodeConnectionType.AiAgent, index: 0 }, + { type: NodeConnectionType.AiAgent, index: 1 }, + ]); + const outputs = ref([]); + + const { nonMainInputs } = useNodeConnections({ inputs, outputs }); + + expect(nonMainInputs.value.length).toBe(2); + expect(nonMainInputs.value).toEqual(inputs.value.slice(1)); + }); + }); + + describe('requiredNonMainInputs', () => { + it('should return required non-main inputs when provided with required non-main inputs', () => { + const inputs = ref([ + { type: NodeConnectionType.Main, index: 0 }, + { type: NodeConnectionType.AiAgent, required: true, index: 0 }, + { type: NodeConnectionType.AiAgent, required: false, index: 1 }, + ]); + const outputs = ref([]); + + const { requiredNonMainInputs } = useNodeConnections({ inputs, outputs }); + + expect(requiredNonMainInputs.value.length).toBe(1); + expect(requiredNonMainInputs.value).toEqual([inputs.value[1]]); + }); + }); + + describe('mainOutputs', () => { + it('should return main outputs when provided with main outputs', () => { + const inputs = ref([]); + const outputs = ref([ + { type: NodeConnectionType.Main, index: 0 }, + { type: NodeConnectionType.Main, index: 1 }, + { type: NodeConnectionType.Main, index: 2 }, + { type: NodeConnectionType.AiAgent, index: 0 }, + ]); + + const { mainOutputs } = useNodeConnections({ inputs, outputs }); + + expect(mainOutputs.value.length).toBe(3); + expect(mainOutputs.value).toEqual(outputs.value.slice(0, 3)); + }); + }); + + describe('nonMainOutputs', () => { + it('should return non-main outputs when provided with non-main outputs', () => { + const inputs = ref([]); + const outputs = ref([ + { type: NodeConnectionType.Main, index: 0 }, + { type: NodeConnectionType.AiAgent, index: 0 }, + { type: NodeConnectionType.AiAgent, index: 1 }, + ]); + + const { nonMainOutputs } = useNodeConnections({ inputs, outputs }); + + expect(nonMainOutputs.value.length).toBe(2); + expect(nonMainOutputs.value).toEqual(outputs.value.slice(1)); + }); + }); +}); diff --git a/packages/editor-ui/src/composables/useNodeConnections.ts b/packages/editor-ui/src/composables/useNodeConnections.ts new file mode 100644 index 0000000000..9b581a4043 --- /dev/null +++ b/packages/editor-ui/src/composables/useNodeConnections.ts @@ -0,0 +1,47 @@ +import type { CanvasElementData } from '@/types'; +import type { MaybeRef } from 'vue'; +import { computed, unref } from 'vue'; +import { NodeConnectionType } from 'n8n-workflow'; + +export function useNodeConnections({ + inputs, + outputs, +}: { + inputs: MaybeRef; + outputs: MaybeRef; +}) { + /** + * Inputs + */ + + const mainInputs = computed(() => + unref(inputs).filter((input) => input.type === NodeConnectionType.Main), + ); + + const nonMainInputs = computed(() => + unref(inputs).filter((input) => input.type !== NodeConnectionType.Main), + ); + + const requiredNonMainInputs = computed(() => + nonMainInputs.value.filter((input) => input.required), + ); + + /** + * Outputs + */ + + const mainOutputs = computed(() => + unref(outputs).filter((output) => output.type === NodeConnectionType.Main), + ); + const nonMainOutputs = computed(() => + unref(outputs).filter((output) => output.type !== NodeConnectionType.Main), + ); + + return { + mainInputs, + nonMainInputs, + requiredNonMainInputs, + mainOutputs, + nonMainOutputs, + }; +} diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 9c79a9d193..b433c3edac 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -4,6 +4,8 @@ import type { NodeCreatorOpenSource, } from './Interface'; import { NodeConnectionType } from 'n8n-workflow'; +import type { CanvasNodeHandleInjectionData, CanvasNodeInjectionData } from '@/types'; +import type { InjectionKey } from 'vue'; export const MAX_WORKFLOW_SIZE = 1024 * 1024 * 16; // Workflow size limit in bytes export const MAX_EXPECTED_REQUEST_SIZE = 2048; // Expected maximum workflow request metadata (i.e. headers) size in bytes @@ -450,6 +452,7 @@ export const enum VIEWS { VARIABLES = 'VariablesView', NEW_WORKFLOW = 'NodeViewNew', WORKFLOW = 'NodeViewExisting', + WORKFLOW_V2 = 'NodeViewV2', DEMO = 'WorkflowDemo', TEMPLATE_IMPORT = 'WorkflowTemplate', WORKFLOW_ONBOARDING = 'WorkflowOnboarding', @@ -483,7 +486,12 @@ export const enum VIEWS { PROJECT_SETTINGS = 'ProjectSettings', } -export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG]; +export const EDITABLE_CANVAS_VIEWS = [ + VIEWS.WORKFLOW, + VIEWS.NEW_WORKFLOW, + VIEWS.EXECUTION_DEBUG, + VIEWS.WORKFLOW_V2, +]; export const enum FAKE_DOOR_FEATURES { ENVIRONMENTS = 'environments', @@ -611,6 +619,7 @@ export const enum STORES { UI = 'ui', USERS = 'users', WORKFLOWS = 'workflows', + WORKFLOWS_V2 = 'workflowsV2', WORKFLOWS_EE = 'workflowsEE', EXECUTIONS = 'executions', NDV = 'ndv', @@ -828,3 +837,11 @@ export const AI_ASSISTANT_EXPERIMENT_URLS = { }; export const AI_ASSISTANT_LOCAL_STORAGE_KEY = 'N8N_AI_ASSISTANT_EXPERIMENT'; + +/** + * Injection Keys + */ + +export const CanvasNodeKey = 'canvasNode' as unknown as InjectionKey; +export const CanvasNodeHandleKey = + 'canvasNodeHandle' as unknown as InjectionKey; diff --git a/packages/editor-ui/src/main.ts b/packages/editor-ui/src/main.ts index 92f696ef9e..db8cc15077 100644 --- a/packages/editor-ui/src/main.ts +++ b/packages/editor-ui/src/main.ts @@ -1,5 +1,10 @@ import { createApp } from 'vue'; +import '@vue-flow/core/dist/style.css'; +import '@vue-flow/core/dist/theme-default.css'; +import '@vue-flow/controls/dist/style.css'; +import '@vue-flow/minimap/dist/style.css'; + import 'vue-json-pretty/lib/styles.css'; import '@jsplumb/browser-ui/css/jsplumbtoolkit.css'; import 'n8n-design-system/css/index.scss'; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts index 9e9b371b9a..5218ea2c3a 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts @@ -10,12 +10,12 @@ import { WorkflowDataProxy } from 'n8n-workflow'; import { createTestWorkflowObject } from '@/__tests__/mocks'; const nodeTypes: INodeTypeData = { - 'test.set': { + 'n8n-nodes-base.set': { sourcePath: '', type: { description: { displayName: 'Set', - name: 'set', + name: 'n8n-nodes-base.set', group: ['input'], version: 1, description: 'Sets a value', @@ -47,7 +47,7 @@ const nodeTypes: INodeTypeData = { const nodes: INode[] = [ { name: 'Start', - type: 'test.set', + type: 'n8n-nodes-base.set', parameters: {}, typeVersion: 1, id: 'uuid-1', @@ -55,7 +55,7 @@ const nodes: INode[] = [ }, { name: 'Function', - type: 'test.set', + type: 'n8n-nodes-base.set', parameters: { functionCode: '// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require("luxon");\n\nconst data = [\n {\n "length": 105\n },\n {\n "length": 160\n },\n {\n "length": 121\n },\n {\n "length": 275\n },\n {\n "length": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));', @@ -66,7 +66,7 @@ const nodes: INode[] = [ }, { name: 'Rename', - type: 'test.set', + type: 'n8n-nodes-base.set', parameters: { value1: 'data', value2: 'initialName', @@ -77,7 +77,7 @@ const nodes: INode[] = [ }, { name: 'End', - type: 'test.set', + type: 'n8n-nodes-base.set', parameters: {}, typeVersion: 1, id: 'uuid-4', diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 0afcbe25ff..9d43e9c0da 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -25,6 +25,7 @@ const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordV const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue'); const MainSidebar = async () => await import('@/components/MainSidebar.vue'); const NodeView = async () => await import('@/views/NodeView.vue'); +const NodeViewV2 = async () => await import('@/views/NodeView.v2.vue'); const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue'); const WorkflowExecutionsLandingPage = async () => await import('@/components/executions/workflow/WorkflowExecutionsLandingPage.vue'); @@ -357,6 +358,23 @@ export const routes = [ path: '/workflow', redirect: '/workflow/new', }, + { + path: '/workflow-v2/:workflowId', + name: VIEWS.WORKFLOW_V2, + components: { + default: NodeViewV2, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + middleware: ['authenticated', 'custom'], + middlewareOptions: { + custom: () => { + return !!localStorage.getItem('features.NodeViewV2'); + }, + }, + }, + }, { path: '/signin', name: VIEWS.SIGNIN, diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts index 4bb61ad677..59d9439573 100644 --- a/packages/editor-ui/src/stores/ui.store.ts +++ b/packages/editor-ui/src/stores/ui.store.ts @@ -195,6 +195,7 @@ export const useUIStore = defineStore(STORES.UI, { // This enables us to set a queue of notifications form outside (another component) // and then show them when the view is initialized pendingNotificationsForViews: {}, + isCreateNodeActive: false, }), getters: { appliedTheme(): AppliedThemeOption { diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index de40b96290..30345ae1b6 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -305,6 +305,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { return workflow.value.nodes.map((node) => ({ ...node })); } + function setNodePosition(id: string, position: INodeUi['position']): void { + const node = workflow.value.nodes.find((n) => n.id === id); + if (!node) return; + + setNodeValue({ name: node.name, key: 'position', value: position }); + } + function getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow { const nodeTypes = getNodeTypes(); let cachedWorkflowId: string | undefined = workflowId.value; @@ -1588,5 +1595,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { resetChatMessages, appendChatMessage, checkIfNodeHasChatParent, + setNodePosition, }; }); diff --git a/packages/editor-ui/src/types/canvas.ts b/packages/editor-ui/src/types/canvas.ts new file mode 100644 index 0000000000..1e11dbc54d --- /dev/null +++ b/packages/editor-ui/src/types/canvas.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +import type { ConnectionTypes, 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'; +import type { ComputedRef, Ref } from 'vue'; + +export type CanvasElementType = 'node' | 'note'; + +export type CanvasConnectionPortType = ConnectionTypes; + +export type CanvasConnectionPort = { + type: CanvasConnectionPortType; + required?: boolean; + index: number; + label?: string; +}; + +export interface CanvasElementPortWithPosition extends CanvasConnectionPort { + position: Position; + offset?: { top?: string; left?: string }; +} + +export interface CanvasElementData { + id: INodeUi['id']; + type: INodeUi['type']; + typeVersion: INodeUi['typeVersion']; + inputs: CanvasConnectionPort[]; + outputs: CanvasConnectionPort[]; + renderType: 'default' | 'trigger' | 'configuration' | 'configurable'; +} + +export type CanvasElement = Node; + +export interface CanvasConnectionData { + source: CanvasConnectionPort; + target: CanvasConnectionPort; + fromNodeName?: string; +} + +export type CanvasConnection = DefaultEdge; + +export interface CanvasPluginContext { + instance: BrowserJsPlumbInstance; +} + +export interface CanvasPlugin { + (ctx: CanvasPluginContext): void; +} + +export interface CanvasNodeInjectionData { + id: Ref; + data: Ref; + label: Ref; + selected: Ref; + nodeType: ComputedRef; +} + +export interface CanvasNodeHandleInjectionData { + label: Ref; +} diff --git a/packages/editor-ui/src/types/index.ts b/packages/editor-ui/src/types/index.ts index 322e345887..4dea4c9f77 100644 --- a/packages/editor-ui/src/types/index.ts +++ b/packages/editor-ui/src/types/index.ts @@ -1,2 +1,3 @@ +export * from './canvas'; export * from './externalHooks'; export * from './pushConnection'; diff --git a/packages/editor-ui/src/utils/canvasUtilsV2.spec.ts b/packages/editor-ui/src/utils/canvasUtilsV2.spec.ts new file mode 100644 index 0000000000..3f4f213f63 --- /dev/null +++ b/packages/editor-ui/src/utils/canvasUtilsV2.spec.ts @@ -0,0 +1,530 @@ +import { + mapLegacyConnectionsToCanvasConnections, + mapLegacyEndpointsToCanvasConnectionPort, + getUniqueNodeName, +} from '@/utils/canvasUtilsV2'; +import type { IConnections, INodeTypeDescription } from 'n8n-workflow'; +import type { CanvasConnection } from '@/types'; +import type { INodeUi } from '@/Interface'; + +vi.mock('uuid', () => ({ + v4: vi.fn(() => 'mock-uuid'), +})); + +describe('mapLegacyConnectionsToCanvasConnections', () => { + it('should map legacy connections to canvas connections', () => { + const legacyConnections: IConnections = { + 'Node A': { + main: [[{ node: 'Node B', type: 'main', index: 0 }]], + }, + }; + const nodes: INodeUi[] = [ + { + id: '1', + name: 'Node A', + type: 'n8n-nodes-base.node', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + { + id: '2', + name: 'Node B', + type: 'n8n-nodes-base.node', + typeVersion: 1, + position: [200, 200], + parameters: {}, + }, + ]; + + const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections( + legacyConnections, + nodes, + ); + + expect(result).toEqual([ + { + id: '[1/main/0][2/main/0]', + source: '1', + target: '2', + sourceHandle: 'outputs/main/0', + targetHandle: 'inputs/main/0', + data: { + fromNodeName: 'Node A', + source: { + index: 0, + type: 'main', + }, + target: { + index: 0, + type: 'main', + }, + }, + }, + ]); + }); + + it('should return empty array when no matching nodes found', () => { + const legacyConnections: IConnections = { + 'Node A': { + main: [[{ node: 'Node B', type: 'main', index: 0 }]], + }, + }; + const nodes: INodeUi[] = []; + + const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections( + legacyConnections, + nodes, + ); + + expect(result).toEqual([]); + }); + + it('should return empty array when no legacy connections provided', () => { + const legacyConnections: IConnections = {}; + const nodes: INodeUi[] = [ + { + id: '1', + name: 'Node A', + type: 'n8n-nodes-base.node', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + { + id: '2', + name: 'Node B', + type: 'n8n-nodes-base.node', + typeVersion: 1, + position: [200, 200], + parameters: {}, + }, + ]; + + const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections( + legacyConnections, + nodes, + ); + + expect(result).toEqual([]); + }); + + it('should map multiple connections between the same nodes', () => { + const legacyConnections: IConnections = { + 'Node A': { + main: [ + [{ node: 'Node B', type: 'main', index: 0 }], + [{ node: 'Node B', type: 'main', index: 1 }], + ], + }, + }; + const nodes: INodeUi[] = [ + { + id: '1', + name: 'Node A', + type: 'n8n-nodes-base.node', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + { + id: '2', + name: 'Node B', + type: 'n8n-nodes-base.node', + typeVersion: 1, + position: [200, 200], + parameters: {}, + }, + ]; + + const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections( + legacyConnections, + nodes, + ); + + expect(result).toEqual([ + { + id: '[1/main/0][2/main/0]', + source: '1', + target: '2', + sourceHandle: 'outputs/main/0', + targetHandle: 'inputs/main/0', + data: { + fromNodeName: 'Node A', + source: { + index: 0, + type: 'main', + }, + target: { + index: 0, + type: 'main', + }, + }, + }, + { + id: '[1/main/1][2/main/1]', + source: '1', + target: '2', + sourceHandle: 'outputs/main/1', + targetHandle: 'inputs/main/1', + data: { + fromNodeName: 'Node A', + source: { + index: 1, + type: 'main', + }, + target: { + index: 1, + type: 'main', + }, + }, + }, + ]); + }); + + it('should map multiple connections from one node to different nodes', () => { + const legacyConnections: IConnections = { + 'Node A': { + main: [ + [{ node: 'Node B', type: 'main', index: 0 }], + [{ node: 'Node C', type: 'main', index: 0 }], + ], + }, + }; + const nodes: INodeUi[] = [ + { + id: '1', + name: 'Node A', + type: 'n8n-nodes-base.node', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + { + id: '2', + name: 'Node B', + type: 'n8n-nodes-base.node', + typeVersion: 1, + position: [200, 200], + parameters: {}, + }, + { + id: '3', + name: 'Node C', + type: 'n8n-nodes-base.node', + typeVersion: 1, + position: [300, 300], + parameters: {}, + }, + ]; + + const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections( + legacyConnections, + nodes, + ); + + expect(result).toEqual([ + { + id: '[1/main/0][2/main/0]', + source: '1', + target: '2', + sourceHandle: 'outputs/main/0', + targetHandle: 'inputs/main/0', + data: { + fromNodeName: 'Node A', + source: { + index: 0, + type: 'main', + }, + target: { + index: 0, + type: 'main', + }, + }, + }, + { + id: '[1/main/1][3/main/0]', + source: '1', + target: '3', + sourceHandle: 'outputs/main/1', + targetHandle: 'inputs/main/0', + data: { + fromNodeName: 'Node A', + source: { + index: 1, + type: 'main', + }, + target: { + index: 0, + type: 'main', + }, + }, + }, + ]); + }); + + it('should map complex node setup with mixed inputs and outputs', () => { + const legacyConnections: IConnections = { + 'Node A': { + main: [[{ node: 'Node B', type: 'main', index: 0 }]], + other: [[{ node: 'Node C', type: 'other', index: 1 }]], + }, + 'Node B': { + main: [[{ node: 'Node C', type: 'main', index: 0 }]], + }, + }; + const nodes: INodeUi[] = [ + { + id: '1', + name: 'Node A', + typeVersion: 1, + type: 'n8n-nodes-base.node', + position: [100, 100], + parameters: {}, + }, + { + id: '2', + name: 'Node B', + typeVersion: 1, + type: 'n8n-nodes-base.node', + position: [200, 200], + parameters: {}, + }, + { + id: '3', + name: 'Node C', + typeVersion: 1, + type: 'n8n-nodes-base.node', + position: [300, 300], + parameters: {}, + }, + ]; + + const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections( + legacyConnections, + nodes, + ); + + expect(result).toEqual([ + { + id: '[1/main/0][2/main/0]', + source: '1', + target: '2', + sourceHandle: 'outputs/main/0', + targetHandle: 'inputs/main/0', + data: { + fromNodeName: 'Node A', + source: { + index: 0, + type: 'main', + }, + target: { + index: 0, + type: 'main', + }, + }, + }, + { + id: '[1/other/0][3/other/1]', + source: '1', + target: '3', + sourceHandle: 'outputs/other/0', + targetHandle: 'inputs/other/1', + data: { + fromNodeName: 'Node A', + source: { + index: 0, + type: 'other', + }, + target: { + index: 1, + type: 'other', + }, + }, + }, + { + id: '[2/main/0][3/main/0]', + source: '2', + target: '3', + sourceHandle: 'outputs/main/0', + targetHandle: 'inputs/main/0', + data: { + fromNodeName: 'Node B', + source: { + index: 0, + type: 'main', + }, + target: { + index: 0, + type: 'main', + }, + }, + }, + ]); + }); + + it('should handle edge cases with invalid data gracefully', () => { + const legacyConnections: IConnections = { + 'Node A': { + main: [ + [{ node: 'Nonexistent Node', type: 'main', index: 0 }], + [{ node: 'Node B', type: 'main', index: 0 }], + ], + }, + }; + const nodes: INodeUi[] = [ + { + id: '1', + name: 'Node A', + type: 'n8n-nodes-base.node', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + { + id: '2', + name: 'Node B', + type: 'n8n-nodes-base.node', + typeVersion: 1, + position: [200, 200], + parameters: {}, + }, + ]; + + const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections( + legacyConnections, + nodes, + ); + + expect(result).toEqual([ + { + id: '[1/main/1][2/main/0]', + source: '1', + target: '2', + sourceHandle: 'outputs/main/1', + targetHandle: 'inputs/main/0', + data: { + fromNodeName: 'Node A', + source: { + index: 1, + type: 'main', + }, + target: { + index: 0, + type: 'main', + }, + }, + }, + ]); + }); +}); + +describe('mapLegacyEndpointsToCanvasConnectionPort', () => { + it('should return an empty array and log a warning when inputs is a string', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const endpoints: INodeTypeDescription['inputs'] = 'some code'; + const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints); + + expect(result).toEqual([]); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Node endpoints have not been evaluated', + 'some code', + ); + + consoleWarnSpy.mockRestore(); + }); + + it('should map string endpoints correctly', () => { + const endpoints: INodeTypeDescription['inputs'] = ['main', 'ai_tool']; + const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints); + + expect(result).toEqual([ + { type: 'main', index: 0, label: undefined }, + { type: 'ai_tool', index: 0, label: undefined }, + ]); + }); + + it('should map object endpoints correctly', () => { + const endpoints: INodeTypeDescription['inputs'] = [ + { type: 'main', displayName: 'Main Input' }, + { type: 'ai_tool', displayName: 'AI Tool', required: true }, + ]; + const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints); + + expect(result).toEqual([ + { type: 'main', index: 0, label: 'Main Input' }, + { type: 'ai_tool', index: 0, label: 'AI Tool', required: true }, + ]); + }); + + it('should map mixed string and object endpoints correctly', () => { + const endpoints: INodeTypeDescription['inputs'] = [ + 'main', + { type: 'ai_tool', displayName: 'AI Tool' }, + 'main', + ]; + const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints); + + expect(result).toEqual([ + { type: 'main', index: 0, label: undefined }, + { type: 'ai_tool', index: 0, label: 'AI Tool' }, + { type: 'main', index: 1, label: undefined }, + ]); + }); + + it('should handle multiple same type object endpoints', () => { + const endpoints: INodeTypeDescription['inputs'] = [ + { type: 'main', displayName: 'Main Input' }, + { type: 'main', displayName: 'Secondary Main Input' }, + ]; + const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints); + + expect(result).toEqual([ + { type: 'main', index: 0, label: 'Main Input' }, + { type: 'main', index: 1, label: 'Secondary Main Input' }, + ]); + }); + + it('should map required and non-required endpoints correctly', () => { + const endpoints: INodeTypeDescription['inputs'] = [ + { type: 'main', displayName: 'Main Input', required: true }, + { type: 'ai_tool', displayName: 'Optional Tool', required: false }, + ]; + const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints); + + expect(result).toEqual([ + { type: 'main', index: 0, label: 'Main Input', required: true }, + { type: 'ai_tool', index: 0, label: 'Optional Tool' }, + ]); + }); +}); + +describe('getUniqueNodeName', () => { + it('should return the original name if it is unique', () => { + const name = 'Node A'; + const existingNames = new Set(['Node B', 'Node C']); + const result = getUniqueNodeName(name, existingNames); + expect(result).toBe(name); + }); + + it('should append a number to the name if it already exists', () => { + const name = 'Node A'; + const existingNames = new Set(['Node A', 'Node B']); + const result = getUniqueNodeName(name, existingNames); + expect(result).toBe('Node A 1'); + }); + + it('should find the next available number for the name', () => { + const name = 'Node A'; + const existingNames = new Set(['Node A', 'Node A 1', 'Node A 2']); + const result = getUniqueNodeName(name, existingNames); + expect(result).toBe('Node A 3'); + }); + + it('should use UUID if more than 99 variations exist', () => { + const name = 'Node A'; + const existingNames = new Set([...Array(100).keys()].map((i) => `Node A ${i}`).concat([name])); + const result = getUniqueNodeName(name, existingNames); + expect(result).toBe('Node A mock-uuid'); + }); +}); diff --git a/packages/editor-ui/src/utils/canvasUtilsV2.ts b/packages/editor-ui/src/utils/canvasUtilsV2.ts new file mode 100644 index 0000000000..4b46d9564b --- /dev/null +++ b/packages/editor-ui/src/utils/canvasUtilsV2.ts @@ -0,0 +1,91 @@ +import type { IConnections, INodeTypeDescription } from 'n8n-workflow'; +import type { INodeUi } from '@/Interface'; +import type { CanvasConnection, CanvasConnectionPortType, CanvasConnectionPort } from '@/types'; +import { v4 as uuid } from 'uuid'; + +export function mapLegacyConnectionsToCanvasConnections( + legacyConnections: IConnections, + nodes: INodeUi[], +): CanvasConnection[] { + const mappedConnections: CanvasConnection[] = []; + + Object.keys(legacyConnections).forEach((fromNodeName) => { + const fromId = nodes.find((node) => node.name === fromNodeName)?.id; + const fromConnectionTypes = Object.keys(legacyConnections[fromNodeName]); + + fromConnectionTypes.forEach((fromConnectionType) => { + const fromPorts = legacyConnections[fromNodeName][fromConnectionType]; + fromPorts.forEach((toPorts, fromIndex) => { + toPorts.forEach((toPort) => { + const toId = nodes.find((node) => node.name === toPort.node)?.id; + const toConnectionType = toPort.type; + const toIndex = toPort.index; + + if (fromId && toId) { + mappedConnections.push({ + id: `[${fromId}/${fromConnectionType}/${fromIndex}][${toId}/${toConnectionType}/${toIndex}]`, + source: fromId, + target: toId, + sourceHandle: `outputs/${fromConnectionType}/${fromIndex}`, + targetHandle: `inputs/${toConnectionType}/${toIndex}`, + data: { + fromNodeName, + source: { + index: fromIndex, + type: fromConnectionType as CanvasConnectionPortType, + }, + target: { + index: toIndex, + type: toConnectionType as CanvasConnectionPortType, + }, + }, + }); + } + }); + }); + }); + }); + + return mappedConnections; +} + +export function mapLegacyEndpointsToCanvasConnectionPort( + endpoints: INodeTypeDescription['inputs'], +): CanvasConnectionPort[] { + if (typeof endpoints === 'string') { + console.warn('Node endpoints have not been evaluated', endpoints); + return []; + } + + return endpoints.map((endpoint, endpointIndex) => { + const type = typeof endpoint === 'string' ? endpoint : endpoint.type; + const label = typeof endpoint === 'string' ? undefined : endpoint.displayName; + const index = + endpoints + .slice(0, endpointIndex + 1) + .filter((e) => (typeof e === 'string' ? e : e.type) === type).length - 1; + const required = typeof endpoint === 'string' ? false : endpoint.required; + + return { + type, + index, + label, + ...(required ? { required } : {}), + }; + }); +} + +export function getUniqueNodeName(name: string, existingNames: Set): string { + if (!existingNames.has(name)) { + return name; + } + + for (let i = 1; i < 100; i++) { + const newName = `${name} ${i}`; + if (!existingNames.has(newName)) { + return newName; + } + } + + return `${name} ${uuid()}`; +} diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue new file mode 100644 index 0000000000..6e30e58e1e --- /dev/null +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -0,0 +1,971 @@ + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34499a5fad..6977605f68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1143,6 +1143,21 @@ importers: '@n8n/permissions': specifier: workspace:* version: link:../@n8n/permissions + '@vue-flow/background': + specifier: ^1.3.0 + version: 1.3.0(@vue-flow/core@1.33.5)(vue@3.4.21) + '@vue-flow/controls': + specifier: ^1.1.1 + version: 1.1.1(@vue-flow/core@1.33.5)(vue@3.4.21) + '@vue-flow/core': + specifier: ^1.33.5 + version: 1.33.5(vue@3.4.21) + '@vue-flow/minimap': + specifier: ^1.4.0 + version: 1.4.0(@vue-flow/core@1.33.5)(vue@3.4.21) + '@vue-flow/node-toolbar': + specifier: ^1.1.0 + version: 1.1.0(@vue-flow/core@1.33.5) '@vueuse/components': specifier: ^10.5.0 version: 10.5.0(vue@3.4.21) @@ -10593,6 +10608,60 @@ packages: path-browserify: 1.0.1 dev: true + /@vue-flow/background@1.3.0(@vue-flow/core@1.33.5)(vue@3.4.21): + resolution: {integrity: sha512-fu/8s9wzSOQIitnSTI10XT3bzTtagh4h8EF2SWwtlDklOZjAaKy75lqv4htHa3wigy/r4LGCOGwLw3Pk88/AxA==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + dependencies: + '@vue-flow/core': 1.33.5(vue@3.4.21) + vue: 3.4.21(typescript@5.4.2) + dev: false + + /@vue-flow/controls@1.1.1(@vue-flow/core@1.33.5)(vue@3.4.21): + resolution: {integrity: sha512-TCoRD5aYZQsM/N7QlPJcIILg1Gxm0O/zoUikxaeadcom1OlKFHutY72agsySJEWM6fTlyb7w8DYCbB4T8YbFoQ==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + dependencies: + '@vue-flow/core': 1.33.5(vue@3.4.21) + vue: 3.4.21(typescript@5.4.2) + dev: false + + /@vue-flow/core@1.33.5(vue@3.4.21): + resolution: {integrity: sha512-Obo+KHmcww/NYGARMqVH1dhd42QeFzV+TNwytrjVgYCoMVCNjs/blCh437TYTsNy4vgX1NKpNwTbQrS+keurgA==} + peerDependencies: + vue: ^3.3.0 + dependencies: + '@vueuse/core': 10.5.0(vue@3.4.21) + d3-drag: 3.0.0 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.4.21(typescript@5.4.2) + transitivePeerDependencies: + - '@vue/composition-api' + dev: false + + /@vue-flow/minimap@1.4.0(@vue-flow/core@1.33.5)(vue@3.4.21): + resolution: {integrity: sha512-GetmN8uOQxIx4ja85VDnIwpZO5SnGIOfYSqUw8MRMbc8CdACun2M2e3FRaMZRaWapclU2ssXms4xHMWrA3YWpw==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + dependencies: + '@vue-flow/core': 1.33.5(vue@3.4.21) + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.4.21(typescript@5.4.2) + dev: false + + /@vue-flow/node-toolbar@1.1.0(@vue-flow/core@1.33.5): + resolution: {integrity: sha512-6RVDHgY+x8m1cXPaEkqPa/RMR90AC1hPHYBK/QVh8k6lJnFPgwJ9PSiYoC4amsUiDK0mF0Py+PlztLJY1ty+4A==} + peerDependencies: + '@vue-flow/core': ^1.12.2 + dependencies: + '@vue-flow/core': 1.33.5(vue@3.4.21) + dev: false + /@vue/compiler-core@3.4.21: resolution: {integrity: sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==} dependencies: @@ -13086,6 +13155,24 @@ packages: yauzl: 2.10.0 dev: true + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + dev: false + + /d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + dev: false + /d3-dsv@2.0.0: resolution: {integrity: sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w==} hasBin: true @@ -13095,6 +13182,53 @@ packages: rw: 1.3.3 dev: false + /d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + dev: false + + /d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dev: false + + /d3-transition@3.0.1(d3-selection@3.0.0): + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + dev: false + + /d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + dev: false + /d@1.0.1: resolution: {integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==} dependencies: