Allow to load workflow templates (#1887)

* implement import

* set name, remove console log

* add validation and such

* remove monday.com package for testing

* clean up code

* await new name

* refactor api requests

* remove unnessary import

* build

* add zoom button

* update positions on loading template

* update error handling

* build

* update zoom to center

* set state to dirty upon leaving

* clean up pr

* refactor func

* refactor redir

* fix lint issue

* refactor func out

* use new endpoint

* revert error changes

* revert error changes

* update logic to find top left node

* zoom to fit when opening workflow

* revert testing change

* update case

* address comments

* reset zoom when opening new workflow

* update endpoint to plural form

* update endpoint

*  Minor improvements

Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Mutasem Aldmour 2021-06-22 20:33:07 +03:00 committed by GitHub
parent 3016978b68
commit 1d5ba3d437
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 237 additions and 44 deletions

View file

@ -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;

View file

@ -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});
}

View file

@ -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 } : {});
}
}
export async function getWorkflowTemplate(templateId: string): Promise<IWorkflowTemplate> {
return await get(TEMPLATES_BASE_URL, `/workflows/templates/${templateId}`);
}

View file

@ -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();

View file

@ -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}<br/>` : '';
this.$showMessage({
title,
message: `
${message}
<br>
${messageLine}
<i>${error.message}</i>
${this.collapsableDetails(error)}`,
type: 'error',

View file

@ -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';

View file

@ -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);

View file

@ -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<IWorkflowsState, IRootState> = {
namespaced: true,
state: {},
actions: {
setNewWorkflowName: async (context: ActionContext<IWorkflowsState, IRootState>): Promise<void> => {
setNewWorkflowName: async (context: ActionContext<IWorkflowsState, IRootState>, name?: string): Promise<void> => {
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<IWorkflowsState, IRootState> = {
return newName;
},
getWorkflowTemplate: async (context: ActionContext<IWorkflowsState, IRootState>, templateId: string): Promise<IWorkflowTemplate> => {
return await getWorkflowTemplate(templateId);
},
},
};

View file

@ -48,5 +48,14 @@ export default new Router({
path: '/',
redirect: '/workflow',
},
{
path: '/workflows/templates/:id',
name: 'WorkflowTemplate',
components: {
default: NodeView,
header: MainHeader,
sidebar: MainSidebar,
},
},
],
});

View file

@ -39,6 +39,9 @@
@closeNodeCreator="closeNodeCreator"
></node-creator>
<div :class="{ 'zoom-menu': true, expanded: !sidebarMenuCollapsed }">
<button @click="zoomToFit" class="button-white" title="Zoom to Fit">
<font-awesome-icon icon="expand"/>
</button>
<button @click="setZoom('in')" class="button-white" title="Zoom In">
<font-awesome-icon icon="search-plus"/>
</button>
@ -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<void> {
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);

View file

@ -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],
});
};