diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index e01b747fbf..5d0863e3f4 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -221,6 +221,15 @@ export interface IWorkflowDataUpdate { tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response } +export interface IWorkflowTemplate { + id: string; + name: string; + workflow: { + nodes: INodeUi[]; + connections: IConnections; + }; +} + // Almost identical to cli.Interfaces.ts export interface IWorkflowDb { id: string; diff --git a/packages/editor-ui/src/api/helpers.ts b/packages/editor-ui/src/api/helpers.ts index df3f5d8ddd..739242c12a 100644 --- a/packages/editor-ui/src/api/helpers.ts +++ b/packages/editor-ui/src/api/helpers.ts @@ -42,15 +42,13 @@ class ResponseError extends Error { } } -export async function makeRestApiRequest(context: IRestApiContext, method: Method, endpoint: string, data?: IDataObject) { - const { baseUrl, sessionId } = context; +async function request(config: {method: Method, baseURL: string, endpoint: string, headers?: IDataObject, data?: IDataObject}) { + const { method, baseURL, endpoint, headers, data } = config; const options: AxiosRequestConfig = { method, url: endpoint, - baseURL: baseUrl, - headers: { - sessionid: sessionId, - }, + baseURL, + headers, }; if (['PATCH', 'POST', 'PUT'].includes(method)) { options.data = data; @@ -60,7 +58,7 @@ export async function makeRestApiRequest(context: IRestApiContext, method: Metho try { const response = await axios.request(options); - return response.data.data; + return response.data; } catch (error) { if (error.message === 'Network Error') { throw new ResponseError('API-Server can not be reached. It is probably down.'); @@ -79,3 +77,20 @@ export async function makeRestApiRequest(context: IRestApiContext, method: Metho throw error; } } + +export async function makeRestApiRequest(context: IRestApiContext, method: Method, endpoint: string, data?: IDataObject) { + const response = await request({ + method, + baseURL: context.baseUrl, + endpoint, + headers: {sessionid: context.sessionId}, + data, + }); + + // @ts-ignore all cli rest api endpoints return data wrapped in `data` key + return response.data; +} + +export async function get(baseURL: string, endpoint: string, params?: IDataObject) { + return await request({method: 'GET', baseURL, endpoint, data: params}); +} diff --git a/packages/editor-ui/src/api/workflows.ts b/packages/editor-ui/src/api/workflows.ts index b8cf319efd..89c2569347 100644 --- a/packages/editor-ui/src/api/workflows.ts +++ b/packages/editor-ui/src/api/workflows.ts @@ -1,6 +1,11 @@ -import { IRestApiContext } from '@/Interface'; -import { makeRestApiRequest } from './helpers'; +import { IRestApiContext, IWorkflowTemplate } from '@/Interface'; +import { makeRestApiRequest, get } from './helpers'; +import { TEMPLATES_BASE_URL } from '@/constants'; export async function getNewWorkflow(context: IRestApiContext, name?: string) { return await makeRestApiRequest(context, 'GET', `/workflows/new`, name ? { name } : {}); -} \ No newline at end of file +} + +export async function getWorkflowTemplate(templateId: string): Promise { + return await get(TEMPLATES_BASE_URL, `/workflows/templates/${templateId}`); +} diff --git a/packages/editor-ui/src/components/mixins/genericHelpers.ts b/packages/editor-ui/src/components/mixins/genericHelpers.ts index fe943beb82..54d24edab0 100644 --- a/packages/editor-ui/src/components/mixins/genericHelpers.ts +++ b/packages/editor-ui/src/components/mixins/genericHelpers.ts @@ -68,6 +68,9 @@ export const genericHelpers = mixins(showMessage).extend({ }, ); }, + setLoadingText (text: string) { + this.loadingService.text = text; + }, stopLoading () { if (this.loadingService !== null) { this.loadingService.close(); diff --git a/packages/editor-ui/src/components/mixins/showMessage.ts b/packages/editor-ui/src/components/mixins/showMessage.ts index 61fe20a5c4..6ed718ca52 100644 --- a/packages/editor-ui/src/components/mixins/showMessage.ts +++ b/packages/editor-ui/src/components/mixins/showMessage.ts @@ -15,12 +15,12 @@ export const showMessage = mixins(externalHooks).extend({ return Notification(messageData); }, - $showError(error: Error, title: string, message: string) { + $showError(error: Error, title: string, message?: string) { + const messageLine = message ? `${message}
` : ''; this.$showMessage({ title, message: ` - ${message} -
+ ${messageLine} ${error.message} ${this.collapsableDetails(error)}`, type: 'error', diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 88d3daf366..2a3ce3cd4b 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -21,6 +21,11 @@ export const BREAKPOINT_MD = 992; export const BREAKPOINT_LG = 1200; export const BREAKPOINT_XL = 1920; + +// templates +export const TEMPLATES_BASE_URL = `https://api.n8n.io/`; +export const START_NODE_TYPE = 'n8n-nodes-base.start'; + // Node creator export const CORE_NODES_CATEGORY = 'Core Nodes'; export const CUSTOM_NODES_CATEGORY = 'Custom Nodes'; diff --git a/packages/editor-ui/src/main.ts b/packages/editor-ui/src/main.ts index 697508d642..91ad8ab948 100644 --- a/packages/editor-ui/src/main.ts +++ b/packages/editor-ui/src/main.ts @@ -51,6 +51,7 @@ import { faEnvelope, faEye, faExclamationTriangle, + faExpand, faExternalLinkAlt, faExchangeAlt, faFile, @@ -139,6 +140,7 @@ library.add(faEdit); library.add(faEnvelope); library.add(faEye); library.add(faExclamationTriangle); +library.add(faExpand); library.add(faExternalLinkAlt); library.add(faExchangeAlt); library.add(faFile); diff --git a/packages/editor-ui/src/modules/workflows.ts b/packages/editor-ui/src/modules/workflows.ts index 8292318052..3654c13de5 100644 --- a/packages/editor-ui/src/modules/workflows.ts +++ b/packages/editor-ui/src/modules/workflows.ts @@ -1,24 +1,25 @@ -import { getNewWorkflow } from '@/api/workflows'; +import { getNewWorkflow, getWorkflowTemplate } from '@/api/workflows'; import { DUPLICATE_POSTFFIX, MAX_WORKFLOW_NAME_LENGTH, DEFAULT_NEW_WORKFLOW_NAME } from '@/constants'; import { ActionContext, Module } from 'vuex'; import { IRootState, IWorkflowsState, + IWorkflowTemplate, } from '../Interface'; const module: Module = { namespaced: true, state: {}, actions: { - setNewWorkflowName: async (context: ActionContext): Promise => { + setNewWorkflowName: async (context: ActionContext, name?: string): Promise => { let newName = ''; try { - const newWorkflow = await getNewWorkflow(context.rootGetters.getRestApiContext); + const newWorkflow = await getNewWorkflow(context.rootGetters.getRestApiContext, name); newName = newWorkflow.name; } catch (e) { // in case of error, default to original name - newName = DEFAULT_NEW_WORKFLOW_NAME; + newName = name || DEFAULT_NEW_WORKFLOW_NAME; } context.commit('setWorkflowName', { newName }, { root: true }); @@ -42,6 +43,9 @@ const module: Module = { return newName; }, + getWorkflowTemplate: async (context: ActionContext, templateId: string): Promise => { + return await getWorkflowTemplate(templateId); + }, }, }; diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 18551097f3..d5b8c94300 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -48,5 +48,14 @@ export default new Router({ path: '/', redirect: '/workflow', }, + { + path: '/workflows/templates/:id', + name: 'WorkflowTemplate', + components: { + default: NodeView, + header: MainHeader, + sidebar: MainSidebar, + }, + }, ], }); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 7a7dfeff9d..b6631ccc80 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -39,6 +39,9 @@ @closeNodeCreator="closeNodeCreator" >
+ @@ -113,7 +116,7 @@ import { } from 'jsplumb'; import { MessageBoxInputData } from 'element-ui/types/message-box'; import { jsPlumb, Endpoint, OnConnectionBindInfo } from 'jsplumb'; -import { NODE_NAME_PREFIX, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants'; +import { NODE_NAME_PREFIX, PLACEHOLDER_EMPTY_WORKFLOW_ID, START_NODE_TYPE } from '@/constants'; import { copyPaste } from '@/components/mixins/copyPaste'; import { externalHooks } from '@/components/mixins/externalHooks'; import { genericHelpers } from '@/components/mixins/genericHelpers'; @@ -133,9 +136,10 @@ import NodeCreator from '@/components/NodeCreator/NodeCreator.vue'; import NodeSettings from '@/components/NodeSettings.vue'; import RunData from '@/components/RunData.vue'; +import { getLeftmostTopNode, getWorkflowCorners } from './helpers'; + import mixins from 'vue-typed-mixins'; import { v4 as uuidv4} from 'uuid'; -import axios from 'axios'; import { IConnection, IConnections, @@ -144,7 +148,6 @@ import { INodeConnections, INodeIssues, INodeTypeDescription, - IRunData, NodeInputConnections, NodeHelpers, Workflow, @@ -155,19 +158,35 @@ import { IExecutionResponse, IExecutionsStopData, IN8nUISettings, - IStartRunData, IWorkflowDb, IWorkflowData, INodeUi, - IRunDataUi, IUpdateInformation, IWorkflowDataUpdate, XYPositon, IPushDataExecutionFinished, ITag, + IWorkflowTemplate, } from '../Interface'; import { mapGetters } from 'vuex'; +const NODE_SIZE = 100; +const DEFAULT_START_POSITION_X = 250; +const DEFAULT_START_POSITION_Y = 300; +const HEADER_HEIGHT = 65; +const SIDEBAR_WIDTH = 65; + +const DEFAULT_START_NODE = { + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [ + DEFAULT_START_POSITION_X, + DEFAULT_START_POSITION_Y, + ] as XYPositon, + parameters: {}, +}; + export default mixins( copyPaste, externalHooks, @@ -311,6 +330,7 @@ export default mixins( nodeViewScale: 1, ctrlKeyPressed: false, stopExecutionInProgress: false, + blankRedirect: false, }; }, beforeDestroy () { @@ -329,7 +349,6 @@ export default mixins( this.$externalHooks().run('nodeView.createNodeActiveChanged', { source: 'add_node_button' }); }, async openExecution (executionId: string) { - this.resetWorkspace(); let data: IExecutionResponse | undefined; try { @@ -352,6 +371,60 @@ export default mixins( this.$externalHooks().run('execution.open', { workflowId: data.workflowData.id, workflowName: data.workflowData.name, executionId }); }, + async openWorkflowTemplate (templateId: string) { + this.setLoadingText('Loading template'); + this.resetWorkspace(); + + let data: IWorkflowTemplate | undefined; + try { + this.$externalHooks().run('template.requested', { templateId }); + data = await this.$store.dispatch('workflows/getWorkflowTemplate', templateId); + + if (!data) { + throw new Error(`Workflow template with id "${templateId}" could not be found!`); + } + + data.workflow.nodes.forEach((node) => { + if (!this.$store.getters.nodeType(node.type)) { + const name = node.type.replace('n8n-nodes-base.', ''); + throw new Error(`The ${name} node is not supported`); + } + }); + } catch (error) { + this.$showError(error, `Couldn't import workflow`); + this.$router.push({ name: 'NodeViewNew' }); + return; + } + + const nodes = data.workflow.nodes; + const hasStartNode = !!nodes.find(node => node.type === START_NODE_TYPE); + + const leftmostTop = getLeftmostTopNode(nodes); + + const diffX = DEFAULT_START_POSITION_X - leftmostTop.position[0]; + const diffY = DEFAULT_START_POSITION_Y - leftmostTop.position[1]; + + data.workflow.nodes.map((node) => { + node.position[0] += diffX + (hasStartNode? 0 : NODE_SIZE * 2); + node.position[1] += diffY; + }); + + if (!hasStartNode) { + data.workflow.nodes.push(DEFAULT_START_NODE); + } + + this.blankRedirect = true; + this.$router.push({ name: 'NodeViewNew' }); + + await this.addNodes(data.workflow.nodes, data.workflow.connections); + await this.$store.dispatch('workflows/setNewWorkflowName', data.name); + this.$nextTick(() => { + this.zoomToFit(); + this.$store.commit('setStateDirty', true); + }); + + this.$externalHooks().run('template.open', { templateId, templateName: data.name, workflow: data.workflow }); + }, async openWorkflow (workflowId: string) { this.resetWorkspace(); @@ -381,6 +454,7 @@ export default mixins( await this.addNodes(data.nodes, data.connections); this.$store.commit('setStateDirty', false); + this.zoomToFit(); this.$externalHooks().run('workflow.open', { workflowId, workflowName: data.name }); @@ -705,17 +779,22 @@ export default mixins( }, setZoom (zoom: string) { + let scale = this.nodeViewScale; if (zoom === 'in') { - this.nodeViewScale *= 1.25; + scale *= 1.25; } else if (zoom === 'out') { - this.nodeViewScale /= 1.25; + scale /= 1.25; } else { - this.nodeViewScale = 1; + scale = 1; } + this.setZoomLevel(scale); + }, - const zoomLevel = this.nodeViewScale; - + setZoomLevel (zoomLevel: number) { + this.nodeViewScale = zoomLevel; // important for background const element = this.instance.getContainer() as HTMLElement; + + // https://docs.jsplumbtoolkit.com/community/current/articles/zooming.html const prependProperties = ['webkit', 'moz', 'ms', 'o']; const scaleString = 'scale(' + zoomLevel + ')'; @@ -729,6 +808,32 @@ export default mixins( this.instance.setZoom(zoomLevel); }, + zoomToFit () { + const nodes = this.$store.getters.allNodes as INodeUi[]; + + const {minX, minY, maxX, maxY} = getWorkflowCorners(nodes); + + const PADDING = NODE_SIZE * 4; + + const editorWidth = window.innerWidth; + const diffX = maxX - minX + SIDEBAR_WIDTH + PADDING; + const scaleX = editorWidth / diffX; + + const editorHeight = window.innerHeight; + const diffY = maxY - minY + HEADER_HEIGHT + PADDING; + const scaleY = editorHeight / diffY; + + const zoomLevel = Math.min(scaleX, scaleY, 1); + let xOffset = (minX * -1) * zoomLevel + SIDEBAR_WIDTH; // find top right corner + xOffset += (editorWidth - SIDEBAR_WIDTH - (maxX - minX + NODE_SIZE) * zoomLevel) / 2; // add padding to center workflow + + let yOffset = (minY * -1) * zoomLevel + HEADER_HEIGHT; // find top right corner + yOffset += (editorHeight - HEADER_HEIGHT - (maxY - minY + NODE_SIZE * 2) * zoomLevel) / 2; // add padding to center workflow + + this.setZoomLevel(zoomLevel); + this.$store.commit('setNodeViewOffsetPosition', {newOffset: [xOffset, yOffset]}); + }, + async stopExecution () { const executionId = this.$store.getters.activeExecutionId; if (executionId === null) { @@ -1406,23 +1511,10 @@ export default mixins( await this.$store.dispatch('workflows/setNewWorkflowName'); this.$store.commit('setStateDirty', false); - // Create start node - const defaultNodes = [ - { - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [ - 250, - 300, - ] as XYPositon, - parameters: {}, - }, - ]; - - await this.addNodes(defaultNodes); + await this.addNodes([DEFAULT_START_NODE]); this.$store.commit('setStateDirty', false); + this.setZoomLevel(1); }, async initView (): Promise { if (this.$route.params.action === 'workflowSave') { @@ -1432,7 +1524,14 @@ export default mixins( return Promise.resolve(); } - if (this.$route.name === 'ExecutionById') { + if (this.blankRedirect) { + this.blankRedirect = false; + } + else if (this.$route.name === 'WorkflowTemplate') { + const templateId = this.$route.params.id; + await this.openWorkflowTemplate(templateId); + } + else if (this.$route.name === 'ExecutionById') { // Load an execution const executionId = this.$route.params.id; await this.openExecution(executionId); diff --git a/packages/editor-ui/src/views/helpers.ts b/packages/editor-ui/src/views/helpers.ts new file mode 100644 index 0000000000..4c215a4c96 --- /dev/null +++ b/packages/editor-ui/src/views/helpers.ts @@ -0,0 +1,42 @@ +import { INodeUi } from "@/Interface"; + +interface ICorners { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +export const getLeftmostTopNode = (nodes: INodeUi[]): INodeUi => { + return nodes.reduce((leftmostTop, node) => { + if (node.position[0] > leftmostTop.position[0] || node.position[1] > leftmostTop.position[1]) { + return leftmostTop; + } + + return node; + }); +}; + +export const getWorkflowCorners = (nodes: INodeUi[]): ICorners => { + return nodes.reduce((accu: ICorners, node: INodeUi) => { + if (node.position[0] < accu.minX) { + accu.minX = node.position[0]; + } + if (node.position[1] < accu.minY) { + accu.minY = node.position[1]; + } + if (node.position[0] > accu.maxX) { + accu.maxX = node.position[0]; + } + if (node.position[1] > accu.maxY) { + accu.maxY = node.position[1]; + } + + return accu; + }, { + minX: nodes[0].position[0], + minY: nodes[0].position[1], + maxX: nodes[0].position[0], + maxY: nodes[0].position[1], + }); +};