Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(editor): Add initial code for NodeView and Canvas rewrite (no-changelog) #9135

Merged
merged 29 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9244ea6
feat: initial canvas v2 commit
alexgrozav Apr 9, 2024
a7b66bc
feat: add VueFlow for canvas rendering
alexgrozav Apr 12, 2024
ee99083
chore: remove deprecated JSPlumb migration
alexgrozav Apr 12, 2024
e8dd041
fix: migrate to css modules
alexgrozav Apr 12, 2024
a534fdb
feat: add background, minimap, controls
alexgrozav Apr 12, 2024
cd41d08
fix: remove unused function
alexgrozav Apr 12, 2024
e5cbebd
Merge remote-tracking branch 'origin/master' into canvas-v2
cstuncsik Apr 16, 2024
242f0da
feat: update main and non-main inputs and outputs
alexgrozav Apr 16, 2024
a213b02
Merge branch 'canvas-v2' of github.com:n8n-io/n8n into canvas-v2
alexgrozav Apr 16, 2024
b0f2347
feat: update connection mapping
alexgrozav Apr 16, 2024
3b0abad
feat: Scaffolding workflow execution
cstuncsik Apr 16, 2024
4b756de
feat: add handle renderer
alexgrozav Apr 16, 2024
6294950
Merge branch 'canvas-v2' of github.com:n8n-io/n8n into canvas-v2
alexgrozav Apr 16, 2024
8d2a5c9
feat: Add pinned data length as label
cstuncsik Apr 16, 2024
ea9bc5f
feat: add multiple handle renderers and handle labels
alexgrozav Apr 22, 2024
c6e9c38
chore: merge master
alexgrozav May 3, 2024
2e92320
chore: merge master
alexgrozav May 3, 2024
86cabdb
feat: add capability to add nodes
alexgrozav May 8, 2024
fdfa7a3
chore: merge master
alexgrozav May 9, 2024
c99ef4a
fix: update package-lock
alexgrozav May 9, 2024
87933d0
feat: add node names
alexgrozav May 9, 2024
a4765ab
feat: update connections and props
alexgrozav May 9, 2024
bd0bdba
chore: remove workflows.store.v2
alexgrozav May 9, 2024
92a373f
chore: fix linting issues
alexgrozav May 9, 2024
defde02
fix: update feature flag name
alexgrozav May 9, 2024
f5511c8
test: add unit tests for canvas components and utils
alexgrozav May 16, 2024
1bff512
fix: fix linting issues
alexgrozav May 16, 2024
661463e
chore: merge master
alexgrozav May 22, 2024
9f8be0a
fix: address PR feedback
alexgrozav May 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading