@@ -302,6 +303,9 @@ export default mixins(
},
},
computed: {
+ areExpressionsDisabled(): boolean {
+ return this.$store.getters['ui/areExpressionsDisabled'];
+ },
codeAutocomplete (): string | undefined {
return this.getArgument('codeAutocomplete') as string | undefined;
},
@@ -419,6 +423,10 @@ export default mixins(
return false;
},
expressionValueComputed (): NodeParameterValue | null {
+ if (this.areExpressionsDisabled) {
+ return this.value;
+ }
+
if (this.node === null) {
return null;
}
@@ -661,6 +669,10 @@ export default mixins(
this.valueChanged(value);
},
openExpressionEdit() {
+ if (this.areExpressionsDisabled) {
+ return;
+ }
+
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();
diff --git a/packages/editor-ui/src/components/Telemetry.vue b/packages/editor-ui/src/components/Telemetry.vue
index ac7978b513..159d5944d0 100644
--- a/packages/editor-ui/src/components/Telemetry.vue
+++ b/packages/editor-ui/src/components/Telemetry.vue
@@ -9,15 +9,43 @@ import { mapGetters } from 'vuex';
export default Vue.extend({
name: 'Telemetry',
+ data() {
+ return {
+ initialised: false,
+ };
+ },
computed: {
...mapGetters('settings', ['telemetry']),
+ isTelemeteryEnabledOnRoute(): boolean {
+ return this.$route.meta && this.$route.meta.telemetry ? !this.$route.meta.telemetry.disabled: true;
+ },
+ },
+ mounted() {
+ this.init();
+ },
+ methods: {
+ init() {
+ if (this.initialised || !this.isTelemeteryEnabledOnRoute) {
+ return;
+ }
+ const opts = this.telemetry;
+ if (opts && opts.enabled) {
+ this.initialised = true;
+ const instanceId = this.$store.getters.instanceId;
+ const logLevel = this.$store.getters['settings/logLevel'];
+ this.$telemetry.init(opts, {instanceId, logLevel, store: this.$store});
+ }
+ },
},
watch: {
- telemetry(opts) {
- if (opts && opts.enabled) {
- this.$telemetry.init(opts, this.$store.getters.instanceId, this.$store.getters['settings/logLevel']);
+ isTelemeteryEnabledOnRoute(enabled) {
+ if (enabled) {
+ this.init();
}
},
+ telemetry() {
+ this.init();
+ },
},
});
diff --git a/packages/editor-ui/src/components/TemplateCard.vue b/packages/editor-ui/src/components/TemplateCard.vue
new file mode 100644
index 0000000000..e4dcbf8f05
--- /dev/null
+++ b/packages/editor-ui/src/components/TemplateCard.vue
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
{{ workflow.name }}
+
+
+
+
+ {{ abbreviateNumber(workflow.totalViews) }}
+
+
+
+
+
+
+
+
By {{ workflow.user.username }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/TemplateDetails.vue b/packages/editor-ui/src/components/TemplateDetails.vue
new file mode 100644
index 0000000000..7ba252f857
--- /dev/null
+++ b/packages/editor-ui/src/components/TemplateDetails.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $locale.baseText('template.details.created') }}
+
+ {{ $locale.baseText('template.details.by') }}
+ {{ template.user.username }}
+ n8n team
+
+
+
+
+ {{ $locale.baseText('template.details.viewed') }}
+ {{ abbreviateNumber(template.totalViews) }}
+ {{ $locale.baseText('template.details.times') }}
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/TemplateDetailsBlock.vue b/packages/editor-ui/src/components/TemplateDetailsBlock.vue
new file mode 100644
index 0000000000..aa6bf787e2
--- /dev/null
+++ b/packages/editor-ui/src/components/TemplateDetailsBlock.vue
@@ -0,0 +1,38 @@
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/TemplateFilters.vue b/packages/editor-ui/src/components/TemplateFilters.vue
new file mode 100644
index 0000000000..41103bfde8
--- /dev/null
+++ b/packages/editor-ui/src/components/TemplateFilters.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+ -
+ resetCategories(value)"
+ />
+
+ -
+ handleCheckboxChanged(value, category)"
+ />
+
+
+
+
+ + {{ `${sortedCategories.length - expandLimit} more` }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/TemplateList.vue b/packages/editor-ui/src/components/TemplateList.vue
new file mode 100644
index 0000000000..7f9fcc685b
--- /dev/null
+++ b/packages/editor-ui/src/components/TemplateList.vue
@@ -0,0 +1,116 @@
+
+
+
+
+ {{ $locale.baseText('templates.workflows') }}
+
+
+
+
+
onCardClick(e, workflow.id)"
+ @useWorkflow="(e) => onUseWorkflow(e, workflow.id)"
+ />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/WorkflowOpen.vue b/packages/editor-ui/src/components/WorkflowOpen.vue
index 4a927519ec..84edc06973 100644
--- a/packages/editor-ui/src/components/WorkflowOpen.vue
+++ b/packages/editor-ui/src/components/WorkflowOpen.vue
@@ -66,7 +66,7 @@ import TagsContainer from '@/components/TagsContainer.vue';
import TagsDropdown from '@/components/TagsDropdown.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { convertToDisplayDate } from './helpers';
-import { WORKFLOW_OPEN_MODAL_KEY } from '../constants';
+import { MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRMED, WORKFLOW_OPEN_MODAL_KEY } from '../constants';
export default mixins(
genericHelpers,
@@ -112,10 +112,14 @@ export default mixins(
});
},
},
- mounted() {
+ async mounted() {
this.filterText = '';
this.filterTagIds = [];
- this.openDialog();
+
+ this.isDataLoading = true;
+ await this.loadActiveWorkflows();
+ await this.loadWorkflows();
+ this.isDataLoading = false;
Vue.nextTick(() => {
// Make sure that users can directly type in the filter
@@ -159,23 +163,32 @@ export default mixins(
const result = this.$store.getters.getStateIsDirty;
if(result) {
- const importConfirm = await this.confirmMessage(
+ const confirmModal = await this.confirmModal(
this.$locale.baseText('workflowOpen.confirmMessage.message'),
this.$locale.baseText('workflowOpen.confirmMessage.headline'),
'warning',
this.$locale.baseText('workflowOpen.confirmMessage.confirmButtonText'),
this.$locale.baseText('workflowOpen.confirmMessage.cancelButtonText'),
+ true,
);
- if (importConfirm === false) {
- return;
- } else {
- // This is used to avoid duplicating the message
+
+ if (confirmModal === MODAL_CONFIRMED) {
+ const saved = await this.saveCurrentWorkflow({}, false);
+ if (saved) this.$store.dispatch('settings/fetchPromptsData');
+
+ this.$router.push({
+ name: 'NodeViewExisting',
+ params: { name: data.id },
+ });
+ } else if (confirmModal === MODAL_CANCEL) {
this.$store.commit('setStateDirty', false);
this.$router.push({
name: 'NodeViewExisting',
params: { name: data.id },
});
+ } else if (confirmModal === MODAL_CLOSE) {
+ return;
}
} else {
this.$router.push({
@@ -186,29 +199,30 @@ export default mixins(
this.$store.commit('ui/closeAllModals');
}
},
- openDialog () {
- this.isDataLoading = true;
- this.restApi().getWorkflows()
- .then(
- (data) => {
- this.workflows = data;
-
- this.workflows.forEach((workflowData: IWorkflowShortResponse) => {
- workflowData.createdAt = convertToDisplayDate(workflowData.createdAt as number);
- workflowData.updatedAt = convertToDisplayDate(workflowData.updatedAt as number);
- });
- this.isDataLoading = false;
- },
- )
- .catch(
- (error: Error) => {
- this.$showError(
- error,
- this.$locale.baseText('workflowOpen.showError.title'),
- );
- this.isDataLoading = false;
- },
+ async loadWorkflows () {
+ try {
+ this.workflows = await this.restApi().getWorkflows();
+ this.workflows.forEach((workflowData: IWorkflowShortResponse) => {
+ workflowData.createdAt = convertToDisplayDate(workflowData.createdAt as number);
+ workflowData.updatedAt = convertToDisplayDate(workflowData.updatedAt as number);
+ });
+ } catch (error) {
+ this.$showError(
+ error,
+ this.$locale.baseText('workflowOpen.showError.title'),
);
+ }
+ },
+ async loadActiveWorkflows () {
+ try {
+ const activeWorkflows = await this.restApi().getActiveWorkflows();
+ this.$store.commit('setActiveWorkflows', activeWorkflows);
+ } catch (error) {
+ this.$showError(
+ error,
+ this.$locale.baseText('workflowOpen.couldNotLoadActiveWorkflows'),
+ );
+ }
},
workflowActiveChanged (data: { id: string, active: boolean }) {
for (const workflow of this.workflows) {
diff --git a/packages/editor-ui/src/components/WorkflowPreview.vue b/packages/editor-ui/src/components/WorkflowPreview.vue
new file mode 100644
index 0000000000..c05ba94688
--- /dev/null
+++ b/packages/editor-ui/src/components/WorkflowPreview.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/helpers.ts b/packages/editor-ui/src/components/helpers.ts
index a15a7396ae..d2c4b2d3fa 100644
--- a/packages/editor-ui/src/components/helpers.ts
+++ b/packages/editor-ui/src/components/helpers.ts
@@ -1,8 +1,21 @@
-import { ERROR_TRIGGER_NODE_TYPE } from '@/constants';
-import { INodeUi } from '@/Interface';
+import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, TEMPLATES_NODES_FILTER } from '@/constants';
+import { INodeUi, ITemplatesNode } from '@/Interface';
import dateformat from 'dateformat';
const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
+const SI_SYMBOL = ['', 'k', 'M', 'G', 'T', 'P', 'E'];
+
+export function abbreviateNumber(num: number) {
+ const tier = (Math.log10(Math.abs(num)) / 3) | 0;
+
+ if (tier === 0) return num;
+
+ const suffix = SI_SYMBOL[tier];
+ const scale = Math.pow(10, tier * 3);
+ const scaled = num / scale;
+
+ return Number(scaled.toFixed(1)) + suffix;
+}
export function convertToDisplayDate (epochTime: number) {
return dateformat(epochTime, 'yyyy-mm-dd HH:MM:ss');
@@ -31,3 +44,18 @@ export function getActivatableTriggerNodes(nodes: INodeUi[]) {
return !node.disabled && node.type !== ERROR_TRIGGER_NODE_TYPE;
});
}
+
+export function filterTemplateNodes(nodes: ITemplatesNode[]) {
+ const notCoreNodes = nodes.filter((node: ITemplatesNode) => {
+ return !(node.categories || []).some(
+ (category) => category.name === CORE_NODES_CATEGORY,
+ );
+ });
+
+ const results = notCoreNodes.length > 0 ? notCoreNodes : nodes;
+ return results.filter((elem) => !TEMPLATES_NODES_FILTER.includes(elem.name));
+}
+
+export function setPageTitle(title: string) {
+ window.document.title = title;
+}
diff --git a/packages/editor-ui/src/components/mixins/copyPaste.ts b/packages/editor-ui/src/components/mixins/copyPaste.ts
index 98852c890d..4e368739f7 100644
--- a/packages/editor-ui/src/components/mixins/copyPaste.ts
+++ b/packages/editor-ui/src/components/mixins/copyPaste.ts
@@ -9,6 +9,9 @@ export const copyPaste = Vue.extend({
data () {
return {
copyPasteElementsGotCreated: false,
+ hiddenInput: null as null | Element,
+ onPaste: null as null | Function,
+ onBeforePaste: null as null | Function,
};
},
mounted () {
@@ -53,6 +56,7 @@ export const copyPaste = Vue.extend({
hiddenInput.setAttribute('type', 'text');
hiddenInput.setAttribute('id', 'hidden-input-copy-paste');
hiddenInput.setAttribute('class', 'hidden-copy-paste');
+ this.hiddenInput = hiddenInput;
document.body.append(hiddenInput);
@@ -64,12 +68,14 @@ export const copyPaste = Vue.extend({
ieClipboardDiv.setAttribute('contenteditable', 'true');
document.body.append(ieClipboardDiv);
- document.addEventListener('beforepaste', () => {
+ this.onBeforePaste = () => {
// @ts-ignore
if (hiddenInput.is(':focus')) {
this.focusIeClipboardDiv(ieClipboardDiv as HTMLDivElement);
}
- }, true);
+ };
+ // @ts-ignore
+ document.addEventListener('beforepaste', this.onBeforePaste, true);
}
let userInput = '';
@@ -90,36 +96,38 @@ export const copyPaste = Vue.extend({
}
});
- // Set clipboard event listeners on the document.
- ['paste'].forEach((event) => {
- document.addEventListener(event, debounce((e) => {
- // Check if the event got emitted from a message box or from something
- // else which should ignore the copy/paste
- // @ts-ignore
- const path = e.path || (e.composedPath && e.composedPath());
- for (let index = 0; index < path.length; index++) {
- if (path[index].className && typeof path[index].className === 'string' && (
- path[index].className.includes('el-message-box') || path[index].className.includes('ignore-key-press')
- )) {
- return;
- }
+ this.onPaste = debounce((e) => {
+ const event = 'paste';
+ // Check if the event got emitted from a message box or from something
+ // else which should ignore the copy/paste
+ // @ts-ignore
+ const path = e.path || (e.composedPath && e.composedPath());
+ for (let index = 0; index < path.length; index++) {
+ if (path[index].className && typeof path[index].className === 'string' && (
+ path[index].className.includes('el-message-box') || path[index].className.includes('ignore-key-press')
+ )) {
+ return;
}
+ }
- if (ieClipboardDiv !== null) {
- this.ieClipboardEvent(event, ieClipboardDiv);
- } else {
- this.standardClipboardEvent(event, e as ClipboardEvent);
- // @ts-ignore
- if (!document.activeElement || (document.activeElement && ['textarea', 'text', 'email', 'password'].indexOf(document.activeElement.type) === -1)) {
- // That it still allows to paste into text, email, password & textarea-fiels we
- // check if we can identify the active element and if so only
- // run it if something else is selected.
- this.focusHiddenArea(hiddenInput);
- e.preventDefault();
- }
+ if (ieClipboardDiv !== null) {
+ this.ieClipboardEvent(event, ieClipboardDiv);
+ } else {
+ this.standardClipboardEvent(event, e as ClipboardEvent);
+ // @ts-ignore
+ if (!document.activeElement || (document.activeElement && ['textarea', 'text', 'email', 'password'].indexOf(document.activeElement.type) === -1)) {
+ // That it still allows to paste into text, email, password & textarea-fiels we
+ // check if we can identify the active element and if so only
+ // run it if something else is selected.
+ this.focusHiddenArea(hiddenInput);
+ e.preventDefault();
}
- }, 1000, { leading: true }));
- });
+ }
+ }, 1000, { leading: true });
+
+ // Set clipboard event listeners on the document.
+ // @ts-ignore
+ document.addEventListener('paste', this.onPaste);
},
methods: {
receivedCopyPasteData (plainTextData: string, event?: ClipboardEvent): void {
@@ -198,4 +206,17 @@ export const copyPaste = Vue.extend({
},
},
+ beforeDestroy() {
+ if (this.hiddenInput) {
+ this.hiddenInput.remove();
+ }
+ if (this.onPaste) {
+ // @ts-ignore
+ document.removeEventListener('paste', this.onPaste);
+ }
+ if (this.onBeforePaste) {
+ // @ts-ignore
+ document.removeEventListener('beforepaste', this.onBeforePaste);
+ }
+ },
});
diff --git a/packages/editor-ui/src/components/mixins/genericHelpers.ts b/packages/editor-ui/src/components/mixins/genericHelpers.ts
index 15502f9ff9..9b015f0ac3 100644
--- a/packages/editor-ui/src/components/mixins/genericHelpers.ts
+++ b/packages/editor-ui/src/components/mixins/genericHelpers.ts
@@ -77,11 +77,12 @@ export const genericHelpers = mixins(showMessage).extend({
async callDebounced (...inputParameters: any[]): Promise
{ // tslint:disable-line:no-any
const functionName = inputParameters.shift() as string;
const debounceTime = inputParameters.shift() as number;
+ const trailing = inputParameters.shift() as boolean;
// @ts-ignore
if (this.debouncedFunctions[functionName] === undefined) {
// @ts-ignore
- this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, { leading: true });
+ this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, trailing ? { trailing: true } : { leading: true } );
}
// @ts-ignore
await this.debouncedFunctions[functionName].apply(this, inputParameters);
diff --git a/packages/editor-ui/src/components/mixins/showMessage.ts b/packages/editor-ui/src/components/mixins/showMessage.ts
index 9bb83b8f50..a7237e062d 100644
--- a/packages/editor-ui/src/components/mixins/showMessage.ts
+++ b/packages/editor-ui/src/components/mixins/showMessage.ts
@@ -150,6 +150,23 @@ export const showMessage = mixins(externalHooks).extend({
}
},
+ async confirmModal (message: string, headline: string, type: MessageType | null = 'warning', confirmButtonText?: string, cancelButtonText?: string, showClose = false): Promise {
+ try {
+ const options: ElMessageBoxOptions = {
+ confirmButtonText: confirmButtonText || this.$locale.baseText('showMessage.ok'),
+ cancelButtonText: cancelButtonText || this.$locale.baseText('showMessage.cancel'),
+ dangerouslyUseHTMLString: true,
+ showClose,
+ ...(type && { type }),
+ };
+
+ await this.$confirm(message, headline, options);
+ return 'confirmed';
+ } catch (e) {
+ return e as string;
+ }
+ },
+
clearAllStickyNotifications() {
stickyNotificationQueue.map((notification: ElNotificationComponent) => {
if (notification) {
diff --git a/packages/editor-ui/src/components/mixins/workflowHelpers.ts b/packages/editor-ui/src/components/mixins/workflowHelpers.ts
index 2e6644a54e..885a4aa65b 100644
--- a/packages/editor-ui/src/components/mixins/workflowHelpers.ts
+++ b/packages/editor-ui/src/components/mixins/workflowHelpers.ts
@@ -451,10 +451,10 @@ export const workflowHelpers = mixins(
}
},
- async saveCurrentWorkflow({name, tags}: {name?: string, tags?: string[]} = {}): Promise {
+ async saveCurrentWorkflow({name, tags}: {name?: string, tags?: string[]} = {}, redirect = true): Promise {
const currentWorkflow = this.$route.params.name;
if (!currentWorkflow) {
- return this.saveAsNewWorkflow({name, tags});
+ return this.saveAsNewWorkflow({name, tags}, redirect);
}
// Workflow exists already so update it
@@ -501,7 +501,7 @@ export const workflowHelpers = mixins(
}
},
- async saveAsNewWorkflow ({name, tags, resetWebhookUrls, openInNewWindow}: {name?: string, tags?: string[], resetWebhookUrls?: boolean, openInNewWindow?: boolean} = {}): Promise {
+ async saveAsNewWorkflow ({name, tags, resetWebhookUrls, openInNewWindow}: {name?: string, tags?: string[], resetWebhookUrls?: boolean, openInNewWindow?: boolean} = {}, redirect = true): Promise {
try {
this.$store.commit('addActiveAction', 'workflowSaving');
@@ -552,10 +552,21 @@ export const workflowHelpers = mixins(
const tagIds = createdTags.map((tag: ITag): string => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds);
- this.$router.push({
- name: 'NodeViewExisting',
- params: { name: workflowData.id as string, action: 'workflowSave' },
- });
+ const templateId = this.$route.query.templateId;
+ if (templateId) {
+ this.$telemetry.track('User saved new workflow from template', {
+ template_id: templateId,
+ workflow_id: workflowData.id,
+ wf_template_repo_session_id: this.$store.getters['templates/previousSessionId'],
+ });
+ }
+
+ if (redirect) {
+ this.$router.push({
+ name: 'NodeViewExisting',
+ params: { name: workflowData.id as string, action: 'workflowSave' },
+ });
+ }
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$store.commit('setStateDirty', false);
@@ -567,7 +578,7 @@ export const workflowHelpers = mixins(
this.$showMessage({
title: this.$locale.baseText('workflowHelpers.showMessage.title'),
- message: this.$locale.baseText('workflowHelpers.showMessage.message') + `"${e.message}"`,
+ message: (e as Error).message,
type: 'error',
});
diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts
index a4001cef03..e7fd5d5e80 100644
--- a/packages/editor-ui/src/constants.ts
+++ b/packages/editor-ui/src/constants.ts
@@ -38,8 +38,7 @@ export const BREAKPOINT_LG = 1200;
export const BREAKPOINT_XL = 1920;
-// templates
-export const TEMPLATES_BASE_URL = `https://api.n8n.io/`;
+export const N8N_IO_BASE_URL = `https://api.n8n.io/`;
// node types
export const CALENDLY_TRIGGER_NODE_TYPE = 'n8n-nodes-base.calendlyTrigger';
@@ -136,6 +135,36 @@ export const CODING_SKILL_KEY = 'codingSkill';
export const OTHER_WORK_AREA_KEY = 'otherWorkArea';
export const OTHER_COMPANY_INDUSTRY_KEY = 'otherCompanyIndustry';
+export const MODAL_CANCEL = 'cancel';
+export const MODAL_CLOSE = 'close';
+export const MODAL_CONFIRMED = 'confirmed';
+
export const VALID_EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const LOCAL_STORAGE_ACTIVATION_FLAG = 'N8N_HIDE_ACTIVATION_ALERT';
+export const HIRING_BANNER = `
+ //////
+ ///////////
+ ///// ////
+ /////////////////// ////
+ ////////////////////// ////
+ /////// /////// //// /////////////
+ //////////// //////////// //// ///////
+ //// //// //// //// ////
+///// ///////////// //////////
+ ///// //// //// //// ////
+ //////////// //////////// //// ////////
+ /////// ////// //// /////////////
+ ///////////// ////
+ ////////// ////
+ //// ////
+ ///////////
+ //////
+
+Love n8n? Help us build the future of automation! https://n8n.io/careers
+`;
+
+export const TEMPLATES_NODES_FILTER = [
+ 'n8n-nodes-base.start',
+ 'n8n-nodes-base.respondToWebhook',
+];
diff --git a/packages/editor-ui/src/modules/credentials.ts b/packages/editor-ui/src/modules/credentials.ts
index efe5e4db23..c890e8a61d 100644
--- a/packages/editor-ui/src/modules/credentials.ts
+++ b/packages/editor-ui/src/modules/credentials.ts
@@ -123,10 +123,16 @@ const module: Module = {
},
actions: {
fetchCredentialTypes: async (context: ActionContext) => {
+ if (context.getters.allCredentialTypes.length > 0) {
+ return;
+ }
const credentialTypes = await getCredentialTypes(context.rootGetters.getRestApiContext);
context.commit('setCredentialTypes', credentialTypes);
},
fetchAllCredentials: async (context: ActionContext) => {
+ if (context.getters.allCredentials.length > 0) {
+ return;
+ }
const credentials = await getAllCredentials(context.rootGetters.getRestApiContext);
context.commit('setCredentials', credentials);
},
diff --git a/packages/editor-ui/src/modules/settings.ts b/packages/editor-ui/src/modules/settings.ts
index 3332f2cc92..ff427f4bc6 100644
--- a/packages/editor-ui/src/modules/settings.ts
+++ b/packages/editor-ui/src/modules/settings.ts
@@ -13,12 +13,14 @@ import Vue from 'vue';
import { getPersonalizedNodeTypes } from './helper';
import { CONTACT_PROMPT_MODAL_KEY, PERSONALIZATION_MODAL_KEY, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import { ITelemetrySettings } from 'n8n-workflow';
+import { testHealthEndpoint } from '@/api/templates';
const module: Module = {
namespaced: true,
state: {
settings: {} as IN8nUISettings,
promptsData: {} as IN8nPrompts,
+ templatesEndpointHealthy: false,
},
getters: {
personalizedNodeTypes(state: ISettingsState): string[] {
@@ -41,6 +43,18 @@ const module: Module = {
isTelemetryEnabled: (state) => {
return state.settings.telemetry && state.settings.telemetry.enabled;
},
+ isInternalUser: (state): boolean => {
+ return state.settings.deploymentType === 'n8n-internal';
+ },
+ isTemplatesEnabled: (state): boolean => {
+ return Boolean(state.settings.templates && state.settings.templates.enabled);
+ },
+ isTemplatesEndpointReachable: (state): boolean => {
+ return state.templatesEndpointHealthy;
+ },
+ templatesHost: (state): string => {
+ return state.settings.templates.host;
+ },
},
mutations: {
setSettings(state: ISettingsState, settings: IN8nUISettings) {
@@ -55,6 +69,9 @@ const module: Module = {
setPromptsData(state: ISettingsState, promptsData: IN8nPrompts) {
Vue.set(state, 'promptsData', promptsData);
},
+ setTemplatesEndpointHealthy(state: ISettingsState) {
+ state.templatesEndpointHealthy = true;
+ },
},
actions: {
async getSettings(context: ActionContext) {
@@ -124,6 +141,11 @@ const module: Module = {
return e;
}
},
+ async testTemplatesEndpoint(context: ActionContext) {
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(), 2000));
+ await Promise.race([testHealthEndpoint(context.getters.templatesHost), timeout]);
+ context.commit('setTemplatesEndpointHealthy', true);
+ },
},
};
diff --git a/packages/editor-ui/src/modules/templates.ts b/packages/editor-ui/src/modules/templates.ts
new file mode 100644
index 0000000000..44c0c379f9
--- /dev/null
+++ b/packages/editor-ui/src/modules/templates.ts
@@ -0,0 +1,282 @@
+import { getCategories, getCollectionById, getCollections, getTemplateById, getWorkflows, getWorkflowTemplate } from '@/api/templates';
+import { ActionContext, Module } from 'vuex';
+import {
+ IRootState,
+ ITemplatesCollection,
+ ITemplatesWorkflow,
+ ITemplatesCategory,
+ ITemplateState,
+ ITemplatesQuery,
+ ITemplatesWorkflowFull,
+ ITemplatesCollectionFull,
+ IWorkflowTemplate,
+} from '../Interface';
+
+import Vue from 'vue';
+
+const TEMPLATES_PAGE_SIZE = 10;
+
+function getSearchKey(query: ITemplatesQuery): string {
+ return JSON.stringify([query.search || '', [...query.categories].sort()]);
+}
+
+const module: Module = {
+ namespaced: true,
+ state: {
+ categories: {},
+ collections: {},
+ workflows: {},
+ collectionSearches: {},
+ workflowSearches: {},
+ currentSessionId: '',
+ previousSessionId: '',
+ },
+ getters: {
+ allCategories(state: ITemplateState) {
+ return Object.values(state.categories).sort((a: ITemplatesCategory, b: ITemplatesCategory) => a.name > b.name ? 1: -1);
+ },
+ getTemplateById(state: ITemplateState) {
+ return (id: string): null | ITemplatesWorkflow => state.workflows[id];
+ },
+ getCollectionById(state: ITemplateState) {
+ return (id: string): null | ITemplatesCollection => state.collections[id];
+ },
+ getCategoryById(state: ITemplateState) {
+ return (id: string): null | ITemplatesCategory => state.categories[id];
+ },
+ getSearchedCollections(state: ITemplateState) {
+ return (query: ITemplatesQuery) => {
+ const searchKey = getSearchKey(query);
+ const search = state.collectionSearches[searchKey];
+ if (!search) {
+ return null;
+ }
+
+ return search.collectionIds.map((collectionId: string) => state.collections[collectionId]);
+ };
+ },
+ getSearchedWorkflows(state: ITemplateState) {
+ return (query: ITemplatesQuery) => {
+ const searchKey = getSearchKey(query);
+ const search = state.workflowSearches[searchKey];
+ if (!search) {
+ return null;
+ }
+
+ return search.workflowIds.map((workflowId: string) => state.workflows[workflowId]);
+ };
+ },
+ getSearchedWorkflowsTotal(state: ITemplateState) {
+ return (query: ITemplatesQuery) => {
+ const searchKey = getSearchKey(query);
+ const search = state.workflowSearches[searchKey];
+
+ return search ? search.totalWorkflows : 0;
+ };
+ },
+ isSearchLoadingMore(state: ITemplateState) {
+ return (query: ITemplatesQuery) => {
+ const searchKey = getSearchKey(query);
+ const search = state.workflowSearches[searchKey];
+
+ return Boolean(search && search.loadingMore);
+ };
+ },
+ isSearchFinished(state: ITemplateState) {
+ return (query: ITemplatesQuery) => {
+ const searchKey = getSearchKey(query);
+ const search = state.workflowSearches[searchKey];
+
+ return Boolean(search && !search.loadingMore && search.totalWorkflows === search.workflowIds.length);
+ };
+ },
+ currentSessionId(state: ITemplateState) {
+ return state.currentSessionId;
+ },
+ previousSessionId(state: ITemplateState) {
+ return state.previousSessionId;
+ },
+ },
+ mutations: {
+ addCategories(state: ITemplateState, categories: ITemplatesCategory[]) {
+ categories.forEach((category: ITemplatesCategory) => {
+ Vue.set(state.categories, category.id, category);
+ });
+ },
+ addCollections(state: ITemplateState, collections: Array) {
+ collections.forEach((collection) => {
+ const workflows = (collection.workflows || []).map((workflow) => ({id: workflow.id}));
+ const cachedCollection = state.collections[collection.id] || {};
+ Vue.set(state.collections, collection.id, {
+ ...cachedCollection,
+ ...collection,
+ workflows,
+ });
+ });
+ },
+ addWorkflows(state: ITemplateState, workflows: Array) {
+ workflows.forEach((workflow: ITemplatesWorkflow) => {
+ const cachedWorkflow = state.workflows[workflow.id] || {};
+ Vue.set(state.workflows, workflow.id, {
+ ...cachedWorkflow,
+ ...workflow,
+ });
+ });
+ },
+ addCollectionSearch(state: ITemplateState, data: {collections: ITemplatesCollection[], query: ITemplatesQuery}) {
+ const collectionIds = data.collections.map((collection) => collection.id);
+ const searchKey = getSearchKey(data.query);
+ Vue.set(state.collectionSearches, searchKey, {
+ collectionIds,
+ });
+ },
+ addWorkflowsSearch(state: ITemplateState, data: {totalWorkflows: number; workflows: ITemplatesWorkflow[], query: ITemplatesQuery}) {
+ const workflowIds = data.workflows.map((workflow) => workflow.id);
+ const searchKey = getSearchKey(data.query);
+ const cachedResults = state.workflowSearches[searchKey];
+ if (!cachedResults) {
+ Vue.set(state.workflowSearches, searchKey, {
+ workflowIds,
+ totalWorkflows: data.totalWorkflows,
+ });
+
+ return;
+ }
+
+ Vue.set(state.workflowSearches, searchKey, {
+ workflowIds: [...cachedResults.workflowIds, ...workflowIds],
+ totalWorkflows: data.totalWorkflows,
+ });
+ },
+ setWorkflowSearchLoading(state: ITemplateState, query: ITemplatesQuery) {
+ const searchKey = getSearchKey(query);
+ const cachedResults = state.workflowSearches[searchKey];
+ if (!cachedResults) {
+ return;
+ }
+
+ Vue.set(state.workflowSearches[searchKey], 'loadingMore', true);
+ },
+ setWorkflowSearchLoaded(state: ITemplateState, query: ITemplatesQuery) {
+ const searchKey = getSearchKey(query);
+ const cachedResults = state.workflowSearches[searchKey];
+ if (!cachedResults) {
+ return;
+ }
+
+ Vue.set(state.workflowSearches[searchKey], 'loadingMore', false);
+ },
+ resetSessionId(state: ITemplateState) {
+ state.previousSessionId = state.currentSessionId;
+ state.currentSessionId = '';
+ },
+ setSessionId(state: ITemplateState) {
+ if (!state.currentSessionId) {
+ state.currentSessionId = `templates-${Date.now()}`;
+ }
+ },
+ },
+ actions: {
+ async getTemplateById(context: ActionContext, templateId: string): Promise {
+ const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
+ const versionCli: string = context.rootGetters['versionCli'];
+ const response = await getTemplateById(apiEndpoint, templateId, { 'n8n-version': versionCli });
+ const template: ITemplatesWorkflowFull = {
+ ...response.workflow,
+ full: true,
+ };
+
+ context.commit('addWorkflows', [template]);
+ return template;
+ },
+ async getCollectionById(context: ActionContext, collectionId: string): Promise {
+ const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
+ const versionCli: string = context.rootGetters['versionCli'];
+ const response = await getCollectionById(apiEndpoint, collectionId, { 'n8n-version': versionCli });
+ const collection: ITemplatesCollectionFull = {
+ ...response.collection,
+ full: true,
+ };
+
+ context.commit('addCollections', [collection]);
+ context.commit('addWorkflows', response.collection.workflows);
+
+ return context.getters.getCollectionById(collectionId);
+ },
+ async getCategories(context: ActionContext): Promise {
+ const cachedCategories: ITemplatesCategory[] = context.getters.allCategories;
+ if (cachedCategories.length) {
+ return cachedCategories;
+ }
+ const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
+ const versionCli: string = context.rootGetters['versionCli'];
+ const response = await getCategories(apiEndpoint, { 'n8n-version': versionCli });
+ const categories = response.categories;
+
+ context.commit('addCategories', categories);
+
+ return categories;
+ },
+ async getCollections(context: ActionContext, query: ITemplatesQuery): Promise {
+ const cachedResults: ITemplatesCollection[] | null = context.getters.getSearchedCollections(query);
+ if (cachedResults) {
+ return cachedResults;
+ }
+
+ const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
+ const versionCli: string = context.rootGetters['versionCli'];
+ const response = await getCollections(apiEndpoint, query, { 'n8n-version': versionCli });
+ const collections = response.collections;
+
+ context.commit('addCollections', collections);
+ context.commit('addCollectionSearch', {query, collections});
+ collections.forEach((collection: ITemplatesCollection) => context.commit('addWorkflows', collection.workflows));
+
+ return collections;
+ },
+ async getWorkflows(context: ActionContext, query: ITemplatesQuery): Promise {
+ const cachedResults: ITemplatesWorkflow[] = context.getters.getSearchedWorkflows(query);
+ if (cachedResults) {
+ return cachedResults;
+ }
+
+ const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
+ const versionCli: string = context.rootGetters['versionCli'];
+
+ const payload = await getWorkflows(apiEndpoint, {...query, skip: 0, limit: TEMPLATES_PAGE_SIZE}, { 'n8n-version': versionCli });
+
+ context.commit('addWorkflows', payload.workflows);
+ context.commit('addWorkflowsSearch', {...payload, query});
+
+ return context.getters.getSearchedWorkflows(query);
+ },
+ async getMoreWorkflows(context: ActionContext, query: ITemplatesQuery): Promise {
+ if (context.getters.isSearchLoadingMore(query) && !context.getters.isSearchFinished(query)) {
+ return [];
+ }
+ const cachedResults: ITemplatesWorkflow[] = context.getters.getSearchedWorkflows(query) || [];
+ const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
+
+ context.commit('setWorkflowSearchLoading', query);
+ try {
+ const payload = await getWorkflows(apiEndpoint, {...query, skip: cachedResults.length, limit: TEMPLATES_PAGE_SIZE});
+
+ context.commit('setWorkflowSearchLoaded', query);
+ context.commit('addWorkflows', payload.workflows);
+ context.commit('addWorkflowsSearch', {...payload, query});
+
+ return context.getters.getSearchedWorkflows(query);
+ } catch (e) {
+ context.commit('setWorkflowSearchLoaded', query);
+ throw e;
+ }
+ },
+ getWorkflowTemplate: async (context: ActionContext, templateId: string): Promise => {
+ const apiEndpoint: string = context.rootGetters['settings/templatesHost'];
+ const versionCli: string = context.rootGetters['versionCli'];
+ return await getWorkflowTemplate(apiEndpoint, templateId, { 'n8n-version': versionCli });
+ },
+ },
+};
+
+export default module;
diff --git a/packages/editor-ui/src/modules/ui.ts b/packages/editor-ui/src/modules/ui.ts
index 331828bbe1..bd9cb9b44d 100644
--- a/packages/editor-ui/src/modules/ui.ts
+++ b/packages/editor-ui/src/modules/ui.ts
@@ -55,8 +55,12 @@ const module: Module = {
modalStack: [],
sidebarMenuCollapsed: true,
isPageLoading: true,
+ currentView: '',
},
getters: {
+ areExpressionsDisabled(state: IUiState) {
+ return state.currentView === 'WorkflowDemo';
+ },
isVersionsOpen: (state: IUiState) => {
return state.modals[VERSIONS_MODAL_KEY].open;
},
@@ -104,6 +108,9 @@ const module: Module = {
toggleSidebarMenuCollapse: (state: IUiState) => {
state.sidebarMenuCollapsed = !state.sidebarMenuCollapsed;
},
+ setCurrentView: (state: IUiState, currentView: string) => {
+ state.currentView = currentView;
+ },
},
actions: {
openModal: async (context: ActionContext, modalKey: string) => {
diff --git a/packages/editor-ui/src/modules/workflows.ts b/packages/editor-ui/src/modules/workflows.ts
index 3654c13de5..8a44f11243 100644
--- a/packages/editor-ui/src/modules/workflows.ts
+++ b/packages/editor-ui/src/modules/workflows.ts
@@ -1,10 +1,9 @@
-import { getNewWorkflow, getWorkflowTemplate } from '@/api/workflows';
+import { getNewWorkflow } 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 = {
@@ -39,14 +38,11 @@ const module: Module = {
newName = newWorkflow.name;
}
catch (e) {
- }
+ }
return newName;
},
- getWorkflowTemplate: async (context: ActionContext, templateId: string): Promise => {
- return await getWorkflowTemplate(templateId);
- },
},
};
-export default module;
\ No newline at end of file
+export default module;
diff --git a/packages/editor-ui/src/n8n-theme.scss b/packages/editor-ui/src/n8n-theme.scss
index 7d865c78b9..acd8ab1c75 100644
--- a/packages/editor-ui/src/n8n-theme.scss
+++ b/packages/editor-ui/src/n8n-theme.scss
@@ -2,11 +2,6 @@
@import "~n8n-design-system/theme/dist/index.css";
-
-body {
- background-color: var(--color-canvas-background);
-}
-
.clickable {
cursor: pointer;
}
diff --git a/packages/editor-ui/src/plugins/components.ts b/packages/editor-ui/src/plugins/components.ts
index 24856be949..7c84fa12a6 100644
--- a/packages/editor-ui/src/plugins/components.ts
+++ b/packages/editor-ui/src/plugins/components.ts
@@ -37,6 +37,7 @@ import MessageBox from 'element-ui/lib/message-box';
import Message from 'element-ui/lib/message';
import Notification from 'element-ui/lib/notification';
import CollapseTransition from 'element-ui/lib/transitions/collapse-transition';
+import VueAgile from 'vue-agile';
// @ts-ignore
import lang from 'element-ui/lib/locale/lang/en';
@@ -50,12 +51,16 @@ import {
N8nInput,
N8nInputLabel,
N8nInputNumber,
+ N8nLoading,
N8nHeading,
+ N8nMarkdown,
N8nMenu,
N8nMenuItem,
N8nSelect,
N8nSpinner,
N8nSquareButton,
+ N8nTags,
+ N8nTag,
N8nText,
N8nTooltip,
N8nOption,
@@ -71,12 +76,16 @@ Vue.use(N8nInfoTip);
Vue.use(N8nInput);
Vue.use(N8nInputLabel);
Vue.use(N8nInputNumber);
+Vue.component('n8n-loading', N8nLoading);
Vue.use(N8nHeading);
+Vue.component('n8n-markdown', N8nMarkdown);
Vue.use(N8nMenu);
Vue.use(N8nMenuItem);
Vue.use(N8nSelect);
Vue.use(N8nSpinner);
Vue.component('n8n-square-button', N8nSquareButton);
+Vue.use(N8nTags);
+Vue.use(N8nTag);
Vue.component('n8n-text', N8nText);
Vue.use(N8nTooltip);
Vue.use(N8nOption);
@@ -111,6 +120,7 @@ Vue.use(Badge);
Vue.use(Card);
Vue.use(ColorPicker);
Vue.use(Container);
+Vue.use(VueAgile);
Vue.component(CollapseTransition.name, CollapseTransition);
@@ -141,7 +151,8 @@ Vue.prototype.$confirm = async (message: string, configOrTitle: string | ElMessa
roundButton: true,
cancelButtonClass: 'btn--cancel',
confirmButtonClass: 'btn--confirm',
- showClose: false,
+ distinguishCancelAndClose: true,
+ showClose: config.showClose || false,
closeOnClickModal: false,
};
diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json
index 005c955d74..806270c2ac 100644
--- a/packages/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/editor-ui/src/plugins/i18n/locales/en.json
@@ -131,7 +131,8 @@
},
"type": "Type",
"updated": "Updated",
- "yourSavedCredentials": "Your saved credentials"
+ "yourSavedCredentials": "Your saved credentials",
+ "errorLoadingCredentials": "Error loading credentials"
},
"dataDisplay": {
"needHelp": "Need help?",
@@ -289,10 +290,10 @@
"message": "Are you sure that you want to delete '{workflowName}'?"
},
"workflowNew": {
- "cancelButtonText": "",
- "confirmButtonText": "Yes, switch workflows and forget changes",
- "headline": "Switch workflows without saving?",
- "message": "When you switch workflows without saving, your current changes will be lost."
+ "cancelButtonText": "Leave without saving",
+ "confirmButtonText": "Save",
+ "headline": "Save changes before leaving?",
+ "message": "If you don't save, you will lose your changes."
}
},
"credentials": "Credentials",
@@ -309,6 +310,7 @@
"importFromFile": "Import from File",
"importFromUrl": "Import from URL",
"new": "New",
+ "newTemplate": "New from template",
"open": "Open",
"prompt": {
"cancel": "@:reusableBaseText.cancel",
@@ -342,6 +344,7 @@
"title": "Execution stopped"
}
},
+ "templates": "Templates",
"workflows": "Workflows"
},
"multipleParameter": {
@@ -488,16 +491,16 @@
"addNode": "Add node",
"confirmMessage": {
"beforeRouteLeave": {
- "cancelButtonText": "",
- "confirmButtonText": "Yes, switch workflows and forget changes",
- "headline": "Switch workflows without saving?",
- "message": "When you switch workflows without saving, your current changes will be lost."
+ "cancelButtonText": "Leave without saving",
+ "confirmButtonText": "Save",
+ "headline": "Save changes before leaving?",
+ "message": "If you don't save, you will lose your changes."
},
"initView": {
- "cancelButtonText": "",
- "confirmButtonText": "Yes, switch workflows and forget changes",
- "headline": "Switch workflows without saving?",
- "message": "When you switch workflows without saving, your current changes will be lost."
+ "cancelButtonText": "Leave without saving",
+ "confirmButtonText": "Save",
+ "headline": "Save changes before leaving?",
+ "message": "If you don't save, you will lose your changes."
},
"receivedCopyPasteData": {
"cancelButtonText": "",
@@ -620,7 +623,8 @@
"refreshList": "Refresh List",
"removeExpression": "Remove Expression",
"resetValue": "Reset Value",
- "selectDateAndTime": "Select date and time"
+ "selectDateAndTime": "Select date and time",
+ "select": "Select"
},
"parameterInputExpanded": {
"openDocs": "Open docs",
@@ -729,6 +733,14 @@
"saved": "Saved",
"saving": "Saving"
},
+ "settings": {
+ "errors": {
+ "connectionError": {
+ "title": "Error connecting to n8n",
+ "message": "Could not connect to server. Refresh to try again"
+ }
+ }
+ },
"showMessage": {
"cancel": "@:reusableBaseText.cancel",
"ok": "OK",
@@ -795,6 +807,38 @@
},
"notBeingUsed": "Not being used"
},
+ "template": {
+ "buttons": {
+ "goBackButton": "Go back",
+ "useThisWorkflowButton": "Use this workflow"
+ },
+ "details": {
+ "appsInTheWorkflow": "Apps in this workflow",
+ "appsInTheCollection": "This collection features",
+ "by": "by",
+ "categories": "Categories",
+ "created": "Created",
+ "details": "Details",
+ "times": "times",
+ "viewed": "Viewed"
+ }
+ },
+ "templates": {
+ "allCategories": "All Categories",
+ "categoriesHeading": "Categories",
+ "collection": "Collection",
+ "collections": "Collections",
+ "collectionsNotFound": "Collection could not be found",
+ "endResult": "Share your own useful workflows through your n8n.io account",
+ "heading": "Workflow templates",
+ "newButton": "New blank workflow",
+ "noSearchResults": "Nothing found. Try adjusting your search to see more.",
+ "searchPlaceholder": "Search workflows",
+ "workflow": "Workflow",
+ "workflows": "Workflows",
+ "workflowsNotFound": "Workflow could not be found",
+ "connectionWarning": "⚠️ There was a problem fetching workflow templates. Check your internet connection."
+ },
"textEdit": {
"edit": "Edit"
},
@@ -898,10 +942,10 @@
"workflowOpen": {
"active": "Active",
"confirmMessage": {
- "cancelButtonText": "",
- "confirmButtonText": "Yes, switch workflows and forget changes",
- "headline": "Switch workflows without saving?",
- "message": "If you do this, your current changes will be lost."
+ "cancelButtonText": "Leave without saving",
+ "confirmButtonText": "Save",
+ "headline": "Save changes before leaving?",
+ "message": "If you don't save, you will lose your changes."
},
"created": "Created",
"name": "@:reusableBaseText.name",
@@ -915,7 +959,8 @@
"message": "This is the current workflow",
"title": "Workflow already open"
},
- "updated": "Updated"
+ "updated": "Updated",
+ "couldNotLoadActiveWorkflows": "Could not load active workflows"
},
"workflowRun": {
"noActiveConnectionToTheServer": "Lost connection to the server",
@@ -1009,5 +1054,15 @@
"yourWorkflowWillNowRegularlyCheck": "Your workflow will now regularly check {serviceName} for events and trigger executions for them.",
"yourWorkflowWillNowListenForEvents": "Your workflow will now listen for events from {serviceName} and trigger executions.",
"gotIt": "Got it"
+ },
+ "workflowPreview": {
+ "showError": {
+ "previewError": {
+ "message": "Unable to preview workflow",
+ "title": "Preview error"
+ },
+ "missingWorkflow": "Missing workflow",
+ "arrayEmpty": "Must have an array of nodes"
+ }
}
}
diff --git a/packages/editor-ui/src/plugins/icons.ts b/packages/editor-ui/src/plugins/icons.ts
index 1a6d086ed6..864f5cc940 100644
--- a/packages/editor-ui/src/plugins/icons.ts
+++ b/packages/editor-ui/src/plugins/icons.ts
@@ -10,12 +10,15 @@ import {
faArrowRight,
faAt,
faBook,
+ faBoxOpen,
faBug,
faCalendar,
faCheck,
faCheckCircle,
faChevronDown,
faChevronUp,
+ faChevronLeft,
+ faChevronRight,
faCode,
faCodeBranch,
faCog,
@@ -99,10 +102,13 @@ addIcon(faArrowLeft);
addIcon(faArrowRight);
addIcon(faAt);
addIcon(faBook);
+addIcon(faBoxOpen);
addIcon(faBug);
addIcon(faCalendar);
addIcon(faCheck);
addIcon(faCheckCircle);
+addIcon(faChevronLeft);
+addIcon(faChevronRight);
addIcon(faChevronDown);
addIcon(faChevronUp);
addIcon(faCode);
diff --git a/packages/editor-ui/src/plugins/telemetry/index.ts b/packages/editor-ui/src/plugins/telemetry/index.ts
index 146d4d1f48..a44e8769d3 100644
--- a/packages/editor-ui/src/plugins/telemetry/index.ts
+++ b/packages/editor-ui/src/plugins/telemetry/index.ts
@@ -3,7 +3,9 @@ import {
ITelemetrySettings,
IDataObject,
} from 'n8n-workflow';
-import { ILogLevel, INodeCreateElement } from "@/Interface";
+import { ILogLevel, INodeCreateElement, IRootState } from "@/Interface";
+import { Route } from "vue-router";
+import { Store } from "vuex";
declare module 'vue/types/vue' {
interface Vue {
@@ -35,7 +37,9 @@ interface IUserNodesPanelSession {
class Telemetry {
- private pageEventQueue: Array<{category?: string, name?: string | null}>;
+ private pageEventQueue: Array<{category: string, route: Route}>;
+ private previousPath: string;
+ private store: Store | null;
private get telemetry() {
// @ts-ignore
@@ -53,17 +57,20 @@ class Telemetry {
constructor() {
this.pageEventQueue = [];
+ this.previousPath = '';
+ this.store = null;
}
- init(options: ITelemetrySettings, instanceId: string, logLevel?: ILogLevel) {
+ init(options: ITelemetrySettings, props: {instanceId: string, logLevel?: ILogLevel, store: Store}) {
if (options.enabled && !this.telemetry) {
if(!options.config) {
return;
}
- const logging = logLevel === 'debug' ? { logLevel: 'DEBUG'} : {};
+ this.store = props.store;
+ const logging = props.logLevel === 'debug' ? { logLevel: 'DEBUG'} : {};
this.loadTelemetryLibrary(options.config.key, options.config.url, { integrations: { All: false }, loadIntegration: false, ...logging});
- this.telemetry.identify(instanceId);
+ this.telemetry.identify(props.instanceId);
this.flushPageEvents();
}
}
@@ -74,14 +81,24 @@ class Telemetry {
}
}
- page(category?: string, name?: string | null) {
+ page(category: string, route: Route) {
if (this.telemetry) {
- this.telemetry.page(category, name);
+ if (route.path === this.previousPath) { // avoid duplicate requests query is changed for example on search page
+ return;
+ }
+ this.previousPath = route.path;
+
+ const pageName = route.name;
+ let properties: {[key: string]: string} = {};
+ if (this.store && route.meta && route.meta.telemetry && typeof route.meta.telemetry.getProperties === 'function') {
+ properties = route.meta.telemetry.getProperties(route, this.store);
+ }
+ this.telemetry.page(category, pageName, properties);
}
else {
this.pageEventQueue.push({
category,
- name,
+ route,
});
}
}
@@ -89,10 +106,8 @@ class Telemetry {
flushPageEvents() {
const queue = this.pageEventQueue;
this.pageEventQueue = [];
- queue.forEach(({category, name}) => {
- if (this.telemetry) {
- this.telemetry.page(category, name);
- }
+ queue.forEach(({category, route}) => {
+ this.page(category, route);
});
}
diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts
index bfd54b0dba..30fe2bc48d 100644
--- a/packages/editor-ui/src/router.ts
+++ b/packages/editor-ui/src/router.ts
@@ -1,8 +1,14 @@
import Vue from 'vue';
-import Router from 'vue-router';
+import Router, { Route } from 'vue-router';
+
+import TemplatesCollectionView from '@/views/TemplatesCollectionView.vue';
import MainHeader from '@/components/MainHeader/MainHeader.vue';
import MainSidebar from '@/components/MainSidebar.vue';
import NodeView from '@/views/NodeView.vue';
+import TemplatesWorkflowView from '@/views/TemplatesWorkflowView.vue';
+import TemplatesSearchView from '@/views/TemplatesSearchView.vue';
+import { Store } from 'vuex';
+import { IRootState } from './Interface';
Vue.use(Router);
@@ -11,6 +17,25 @@ export default new Router({
// @ts-ignore
base: window.BASE_PATH === '/%BASE_PATH%/' ? '/' : window.BASE_PATH,
routes: [
+ {
+ path: '/collections/:id',
+ name: 'TemplatesCollectionView',
+ components: {
+ default: TemplatesCollectionView,
+ sidebar: MainSidebar,
+ },
+ meta: {
+ templatesEnabled: true,
+ telemetry: {
+ getProperties(route: Route, store: Store) {
+ return {
+ collection_id: route.params.id,
+ wf_template_repo_session_id: store.getters['templates/currentSessionId'],
+ };
+ },
+ },
+ },
+ },
{
path: '/execution/:id',
name: 'ExecutionById',
@@ -19,6 +44,46 @@ export default new Router({
header: MainHeader,
sidebar: MainSidebar,
},
+ meta: {
+ nodeView: true,
+ },
+ },
+ {
+ path: '/templates/:id',
+ name: 'TemplatesWorkflowView',
+ components: {
+ default: TemplatesWorkflowView,
+ sidebar: MainSidebar,
+ },
+ meta: {
+ templatesEnabled: true,
+ telemetry: {
+ getProperties(route: Route, store: Store) {
+ return {
+ template_id: route.params.id,
+ wf_template_repo_session_id: store.getters['templates/currentSessionId'],
+ };
+ },
+ },
+ },
+ },
+ {
+ path: '/templates/',
+ name: 'TemplatesSearchView',
+ components: {
+ default: TemplatesSearchView,
+ sidebar: MainSidebar,
+ },
+ meta: {
+ templatesEnabled: true,
+ telemetry: {
+ getProperties(route: Route, store: Store) {
+ return {
+ wf_template_repo_session_id: store.getters['templates/currentSessionId'],
+ };
+ },
+ },
+ },
},
{
path: '/workflow',
@@ -28,6 +93,9 @@ export default new Router({
header: MainHeader,
sidebar: MainSidebar,
},
+ meta: {
+ nodeView: true,
+ },
},
{
path: '/workflow/:name',
@@ -37,10 +105,9 @@ export default new Router({
header: MainHeader,
sidebar: MainSidebar,
},
- },
- {
- path: '/',
- redirect: '/workflow',
+ meta: {
+ nodeView: true,
+ },
},
{
path: '/workflows/templates/:id',
@@ -50,6 +117,9 @@ export default new Router({
header: MainHeader,
sidebar: MainSidebar,
},
+ meta: {
+ templatesEnabled: true,
+ },
},
{
path: '/workflows/demo',
@@ -57,6 +127,12 @@ export default new Router({
components: {
default: NodeView,
},
+ meta: {
+ nodeView: true,
+ telemetry: {
+ disabled: true,
+ },
+ },
},
],
});
diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts
index 8cd1d6c643..e97ab47731 100644
--- a/packages/editor-ui/src/store.ts
+++ b/packages/editor-ui/src/store.ts
@@ -37,6 +37,7 @@ import settings from './modules/settings';
import ui from './modules/ui';
import workflows from './modules/workflows';
import versions from './modules/versions';
+import templates from './modules/templates';
Vue.use(Vuex);
@@ -94,6 +95,7 @@ const modules = {
credentials,
tags,
settings,
+ templates,
workflows,
versions,
ui,
diff --git a/packages/editor-ui/src/views/LoadingView.vue b/packages/editor-ui/src/views/LoadingView.vue
new file mode 100644
index 0000000000..55a051b3d3
--- /dev/null
+++ b/packages/editor-ui/src/views/LoadingView.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue
index 14810077ca..15be05eac3 100644
--- a/packages/editor-ui/src/views/NodeView.vue
+++ b/packages/editor-ui/src/views/NodeView.vue
@@ -104,7 +104,6 @@
@click.stop="clearExecutionData()"
/>
-