mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
✨ 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:
parent
3016978b68
commit
1d5ba3d437
|
@ -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;
|
||||
|
|
|
@ -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});
|
||||
}
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -48,5 +48,14 @@ export default new Router({
|
|||
path: '/',
|
||||
redirect: '/workflow',
|
||||
},
|
||||
{
|
||||
path: '/workflows/templates/:id',
|
||||
name: 'WorkflowTemplate',
|
||||
components: {
|
||||
default: NodeView,
|
||||
header: MainHeader,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
42
packages/editor-ui/src/views/helpers.ts
Normal file
42
packages/editor-ui/src/views/helpers.ts
Normal 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],
|
||||
});
|
||||
};
|
Loading…
Reference in a new issue