Skip to content

Commit

Permalink
feat(editor): Add initial code for NodeView and Canvas rewrite (no-ch…
Browse files Browse the repository at this point in the history
…angelog) (#9135)

Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com>
  • Loading branch information
alexgrozav and cstuncsik committed May 23, 2024
1 parent 8566301 commit 70948ec
Show file tree
Hide file tree
Showing 49 changed files with 4,208 additions and 21 deletions.
5 changes: 5 additions & 0 deletions packages/editor-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1317,6 +1317,7 @@ export interface UIState {
pendingNotificationsForViews: {
[key in VIEWS]?: NotificationOptions[];
};
isCreateNodeActive: boolean;
}

export type IFakeDoor = {
Expand Down Expand Up @@ -1898,7 +1899,7 @@ export type AddedNodesAndConnections = {
export type ToggleNodeCreatorOptions = {
createNodeActive: boolean;
source?: NodeCreatorOpenSource;
nodeCreatorView?: string;
nodeCreatorView?: NodeFilterType;
};

export type AppliedThemeOption = 'light' | 'dark';
Expand Down
87 changes: 87 additions & 0 deletions packages/editor-ui/src/__tests__/data/canvas.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<CanvasElement, 'data'> & { data: Partial<CanvasElementData> }
> = {}): 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}` } : {}),
};
}
1 change: 1 addition & 0 deletions packages/editor-ui/src/__tests__/data/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './canvas';
14 changes: 13 additions & 1 deletion packages/editor-ui/src/__tests__/defaults.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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: {
Expand All @@ -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[] {
Expand Down
14 changes: 7 additions & 7 deletions packages/editor-ui/src/__tests__/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,29 @@ 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<INodeTypeData>((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 {
return NodeHelpers.getVersionedNodeType(getByName(nodeType), version);
}

return {
getKnownTypes,
getByName,
getByNameAndVersion,
};
Expand Down
3 changes: 2 additions & 1 deletion packages/editor-ui/src/__tests__/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export type RenderOptions = Parameters<typeof render>[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;
},
};
Expand Down
8 changes: 4 additions & 4 deletions packages/editor-ui/src/__tests__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand Down Expand Up @@ -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) => {
Expand Down
112 changes: 112 additions & 0 deletions packages/editor-ui/src/components/canvas/Canvas.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }]]);
});
});
Loading

0 comments on commit 70948ec

Please sign in to comment.