Introduce telemetry (#2099)

* introduce analytics

* add user survey backend

* add user survey backend

* set answers on survey submit

Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>

* change name to personalization

* lint

Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>

* N8n 2495 add personalization modal (#2280)

* update modals

* add onboarding modal

* implement questions

* introduce analytics

* simplify impl

* implement survey handling

* add personalized cateogry

* update modal behavior

* add thank you view

* handle empty cases

* rename modal

* standarize modal names

* update image, add tags to headings

* remove unused file

* remove unused interfaces

* clean up footer spacing

* introduce analytics

* refactor to fix bug

* update endpoint

* set min height

* update stories

* update naming from questions to survey

* remove spacing after core categories

* fix bug in logic

* sort nodes

* rename types

* merge with be

* rename userSurvey

* clean up rest api

* use constants for keys

* use survey keys

* clean up types

* move personalization to its own file

Co-authored-by: ahsan-virani <ahsan.virani@gmail.com>

* Survey new options (#2300)

* split up options

* fix quotes

* remove unused import

* add user created workflow event (#2301)

* simplify env vars

* fix versionCli on FE

* update personalization env

* fix event User opened Credentials panel

* fix select modal spacing

* fix nodes panel event

* fix workflow id in workflow execute event

* improve telemetry error logging

* fix config and stop process events

* add flush call on n8n stop

* ready for release

* improve telemetry process exit

* fix merge

* improve n8n stop events

Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
Co-authored-by: Mutasem <mutdmour@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Ahsan Virani
2021-10-19 05:57:49 +02:00
committed by GitHub
parent 4b857b19ac
commit 421dd72224
100 changed files with 2223 additions and 550 deletions

View File

@@ -9,12 +9,18 @@
<div id="content">
<router-view />
</div>
<Telemetry />
</div>
</template>
<script lang="ts">
import Telemetry from './components/Telemetry.vue';
export default {
name: 'App',
components: {
Telemetry,
},
};
</script>

View File

@@ -18,6 +18,7 @@ import {
IRun,
IRunData,
ITaskData,
ITelemetrySettings,
WorkflowExecuteMode,
} from 'n8n-workflow';
@@ -129,7 +130,6 @@ export interface IRestApi {
getPastExecutions(filter: object, limit: number, lastId?: string | number, firstId?: string | number): Promise<IExecutionsListResponse>;
stopCurrentExecution(executionId: string): Promise<IExecutionsStopData>;
makeRestApiRequest(method: string, endpoint: string, data?: any): Promise<any>; // tslint:disable-line:no-any
getSettings(): Promise<IN8nUISettings>;
getNodeTypes(onlyLatest?: boolean): Promise<INodeTypeDescription[]>;
getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise<INodeTypeDescription[]>;
getNodeParameterOptions(nodeTypeAndVersion: INodeTypeNameVersion, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise<INodePropertyOptions[]>;
@@ -437,6 +437,17 @@ export interface IVersionNotificationSettings {
infoUrl: string;
}
export type IPersonalizationSurveyKeys = 'companySize' | 'codingSkill' | 'workArea' | 'otherWorkArea';
export type IPersonalizationSurveyAnswers = {
[key in IPersonalizationSurveyKeys]: string | null
};
export interface IPersonalizationSurvey {
answers?: IPersonalizationSurveyAnswers;
shouldShow: boolean;
}
export interface IN8nUISettings {
endpointWebhook: string;
endpointWebhookTest: string;
@@ -457,6 +468,8 @@ export interface IN8nUISettings {
};
versionNotifications: IVersionNotificationSettings;
instanceId: string;
personalizationSurvey?: IPersonalizationSurvey;
telemetry: ITelemetrySettings;
}
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
@@ -599,6 +612,7 @@ export interface IRootState {
workflow: IWorkflowDb;
sidebarMenuItems: IMenuItem[];
instanceId: string;
telemetry: ITelemetrySettings | null;
}
export interface ICredentialTypeMap {
@@ -636,6 +650,10 @@ export interface IUiState {
isPageLoading: boolean;
}
export interface ISettingsState {
settings: IN8nUISettings;
}
export interface IVersionsState {
versionNotificationSettings: IVersionNotificationSettings;
nextVersions: IVersion[];

View File

@@ -0,0 +1,12 @@
import { IDataObject } from 'n8n-workflow';
import { IRestApiContext, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface';
import { makeRestApiRequest } from './helpers';
export async function getSettings(context: IRestApiContext): Promise<IN8nUISettings> {
return await makeRestApiRequest(context, 'GET', '/settings');
}
export async function submitPersonalizationSurvey(context: IRestApiContext, params: IPersonalizationSurveyAnswers): Promise<void> {
await makeRestApiRequest(context, 'POST', '/user-survey', params as unknown as IDataObject);
}

View File

@@ -40,7 +40,7 @@
<n8n-info-tip v-if="documentationUrl && credentialProperties.length">
Need help filling out these fields?
<a :href="documentationUrl" target="_blank">Open docs</a>
<a :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open docs</a>
</n8n-info-tip>
<CopyInput
@@ -165,9 +165,17 @@ export default Vue.extend({
},
},
methods: {
onDataChange(event: { name: string; value: string | number | boolean | Date | null }): void {
onDataChange (event: { name: string; value: string | number | boolean | Date | null }): void {
this.$emit('change', event);
},
onDocumentationUrlClick (): void {
this.$telemetry.track('User clicked credential modal docs link', {
docs_link: this.documentationUrl,
credential_type: this.credentialTypeName,
source: 'modal',
workflow_id: this.$store.getters.workflowId,
});
},
},
watch: {
showOAuthSuccessBanner(newValue, oldValue) {

View File

@@ -668,6 +668,8 @@ export default mixins(showMessage, nodeHelpers).extend({
credentialTypeData: this.credentialData,
});
this.$telemetry.track('User created credentials', { credential_type: credentialDetails.type, workflow_id: this.$store.getters.workflowId });
return credential;
},

View File

@@ -1,10 +1,11 @@
<template>
<div v-if="dialogVisible">
<el-dialog :visible="dialogVisible" append-to-body width="80%" title="Credentials" :before-close="closeDialog">
<div class="text-very-light">
Your saved credentials:
</div>
<Modal
:name="CREDENTIAL_LIST_MODAL_KEY"
width="80%"
title="Credentials"
>
<template v-slot:content>
<n8n-heading tag="h3" size="small" color="text-light">Your saved credentials:</n8n-heading>
<div class="new-credentials-button">
<n8n-button
title="Create New Credentials"
@@ -31,8 +32,8 @@
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
</Modal>
</template>
<script lang="ts">
@@ -46,6 +47,9 @@ import { mapGetters } from "vuex";
import mixins from 'vue-typed-mixins';
import { convertToDisplayDate } from './helpers';
import { CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY } from '@/constants';
import Modal from './Modal.vue';
export default mixins(
externalHooks,
@@ -54,9 +58,14 @@ export default mixins(
showMessage,
).extend({
name: 'CredentialsList',
props: [
'dialogVisible',
],
components: {
Modal,
},
data() {
return {
CREDENTIAL_LIST_MODAL_KEY,
};
},
computed: {
...mapGetters('credentials', ['allCredentials']),
credentialsToDisplay() {
@@ -76,25 +85,21 @@ export default mixins(
}, []);
},
},
watch: {
dialogVisible (newValue) {
this.$externalHooks().run('credentialsList.dialogVisibleChanged', { dialogVisible: newValue });
},
mounted() {
this.$externalHooks().run('credentialsList.mounted');
this.$telemetry.track('User opened Credentials panel', { workflow_id: this.$store.getters.workflowId });
},
destroyed() {
this.$externalHooks().run('credentialsList.destroyed');
},
methods: {
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
createCredential () {
this.$store.dispatch('ui/openCredentialsSelectModal');
this.$store.dispatch('ui/openModal', CREDENTIAL_SELECT_MODAL_KEY);
},
editCredential (credential: ICredentialsResponse) {
this.$store.dispatch('ui/openExisitngCredential', { id: credential.id});
this.$telemetry.track('User opened Credential modal', { credential_type: credential.type, source: 'primary_menu', new_credential: false, workflow_id: this.$store.getters.workflowId });
},
async deleteCredential (credential: ICredentialsResponse) {
@@ -130,7 +135,7 @@ export default mixins(
.new-credentials-button {
float: right;
position: relative;
top: -15px;
margin-bottom: var(--spacing-2xs);
}
.cred-operations {

View File

@@ -1,6 +1,6 @@
<template>
<Modal
:name="modalName"
:name="CREDENTIAL_SELECT_MODAL_KEY"
:eventBus="modalBus"
width="50%"
:center="true"
@@ -10,7 +10,7 @@
<h2 :class="$style.title">Add new credential</h2>
</template>
<template slot="content">
<div :class="$style.container">
<div>
<div :class="$style.subtitle">Select an app or service to connect to</div>
<n8n-select
filterable
@@ -51,6 +51,7 @@ import Vue from 'vue';
import { mapGetters } from "vuex";
import Modal from './Modal.vue';
import { CREDENTIAL_SELECT_MODAL_KEY } from '../constants';
export default Vue.extend({
name: 'CredentialsSelectModal',
@@ -69,16 +70,12 @@ export default Vue.extend({
return {
modalBus: new Vue(),
selected: '',
CREDENTIAL_SELECT_MODAL_KEY,
};
},
computed: {
...mapGetters('credentials', ['allCredentialTypes']),
},
props: {
modalName: {
type: String,
},
},
methods: {
onSelect(type: string) {
this.selected = type;
@@ -86,16 +83,13 @@ export default Vue.extend({
openCredentialType () {
this.modalBus.$emit('close');
this.$store.dispatch('ui/openNewCredential', { type: this.selected });
this.$telemetry.track('User opened Credential modal', { credential_type: this.selected, source: 'primary_menu', new_credential: true, workflow_id: this.$store.getters.workflowId });
},
},
});
</script>
<style module lang="scss">
.container {
margin-bottom: var(--spacing-l);
}
.title {
font-size: var(--font-size-xl);
line-height: var(--font-line-height-regular);

View File

@@ -94,6 +94,7 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
node (node, oldNode) {
if(node && !oldNode) {
this.$externalHooks().run('dataDisplay.nodeTypeChanged', { nodeSubtitle: this.getNodeSubtitle(node, this.nodeType, this.getWorkflow()) });
this.$telemetry.track('User opened node modal', { node_type: this.nodeType ? this.nodeType.name : '', workflow_id: this.$store.getters.workflowId });
}
},
},

View File

@@ -10,26 +10,22 @@
>
<template v-slot:content>
<div :class="$style.content">
<el-row>
<n8n-input
v-model="name"
ref="nameInput"
placeholder="Enter workflow name"
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
/>
</el-row>
<el-row>
<TagsDropdown
:createEnabled="true"
:currentTagIds="currentTagIds"
:eventBus="dropdownBus"
@blur="onTagsBlur"
@esc="onTagsEsc"
@update="onTagsUpdate"
placeholder="Choose or create a tag"
ref="dropdown"
/>
</el-row>
<n8n-input
v-model="name"
ref="nameInput"
placeholder="Enter workflow name"
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
/>
<TagsDropdown
:createEnabled="true"
:currentTagIds="currentTagIds"
:eventBus="dropdownBus"
@blur="onTagsBlur"
@esc="onTagsEsc"
@update="onTagsUpdate"
placeholder="Choose or create a tag"
ref="dropdown"
/>
</div>
</template>
<template v-slot:footer="{ close }">
@@ -54,7 +50,7 @@ import Modal from "./Modal.vue";
export default mixins(showMessage, workflowHelpers).extend({
components: { TagsDropdown, Modal },
name: "DuplicateWorkflow",
props: ["dialogVisible", "modalName", "isActive"],
props: ["modalName", "isActive"],
data() {
const currentTagIds = this.$store.getters[
"workflowTags"
@@ -113,12 +109,18 @@ export default mixins(showMessage, workflowHelpers).extend({
return;
}
const currentWorkflowId = this.$store.getters.workflowId;
this.$data.isSaving = true;
const saved = await this.saveAsNewWorkflow({name, tags: this.currentTagIds, resetWebhookUrls: true, openInNewWindow: true});
if (saved) {
this.closeDialog();
this.$telemetry.track('User duplicated workflow', {
old_workflow_id: currentWorkflowId,
workflow_id: this.$store.getters.workflowId,
});
}
this.$data.isSaving = false;
@@ -132,8 +134,8 @@ export default mixins(showMessage, workflowHelpers).extend({
<style lang="scss" module>
.content {
> div {
margin-bottom: 15px;
> *:not(:last-child) {
margin-bottom: var(--spacing-m);
}
}

View File

@@ -580,6 +580,7 @@ export default mixins(
this.handleAutoRefreshToggle();
this.$externalHooks().run('executionsList.openDialog');
this.$telemetry.track('User opened Executions log', { workflow_id: this.$store.getters.workflowId });
},
async retryExecution (execution: IExecutionShortResponse, loadWorkflow?: boolean) {
this.isDataLoading = true;

View File

@@ -97,9 +97,7 @@ export default mixins(
},
itemSelected (eventData: IVariableItemSelected) {
// User inserted item from Expression Editor variable selector
(this.$refs.inputFieldExpression as any).itemSelected(eventData); // tslint:disable-line:no-any
this.$externalHooks().run('expressionEdit.itemSelected', { parameter: this.parameter, value: this.value, selectedItem: eventData });
},
},
@@ -110,6 +108,10 @@ export default mixins(
const resolvedExpressionValue = this.$refs.expressionResult && (this.$refs.expressionResult as any).getValue() || undefined; // tslint:disable-line:no-any
this.$externalHooks().run('expressionEdit.dialogVisibleChanged', { dialogVisible: newValue, parameter: this.parameter, value: this.value, resolvedExpressionValue });
if (!newValue) {
this.$telemetry.track('User closed Expression Editor', { empty_expression: (this.value === '=') || (this.value === '={{}}') || !this.value, workflow_id: this.$store.getters.workflowId });
}
},
},
});

View File

@@ -68,7 +68,7 @@
<SaveButton
:saved="!this.isDirty && !this.isNewWorkflow"
:disabled="isWorkflowSaving"
@click="saveCurrentWorkflow"
@click="onSaveButtonClick"
/>
</template>
</PushConnectionTracker>
@@ -135,11 +135,14 @@ export default mixins(workflowHelpers).extend({
isWorkflowSaving(): boolean {
return this.$store.getters.isActionActive("workflowSaving");
},
currentWorkflowId() {
currentWorkflowId(): string {
return this.$route.params.name;
},
},
methods: {
onSaveButtonClick () {
this.saveCurrentWorkflow(undefined);
},
onTagsEditEnable() {
this.$data.appliedTagIds = this.currentWorkflowTagIds;
this.$data.isTagsEditEnabled = true;
@@ -168,6 +171,8 @@ export default mixins(workflowHelpers).extend({
this.$data.tagsSaving = true;
const saved = await this.saveCurrentWorkflow({ tags });
this.$telemetry.track('User edited workflow tags', { workflow_id: this.currentWorkflowId as string, new_tag_count: tags.length });
this.$data.tagsSaving = false;
if (saved) {
this.$data.isTagsEditEnabled = false;

View File

@@ -2,7 +2,6 @@
<div id="side-menu">
<about :dialogVisible="aboutDialogVisible" @closeDialog="closeAboutDialog"></about>
<executions-list :dialogVisible="executionsListDialogVisible" @closeDialog="closeExecutionsListOpenDialog"></executions-list>
<credentials-list :dialogVisible="credentialOpenDialogVisible" @closeDialog="closeCredentialOpenDialog"></credentials-list>
<input type="file" ref="importFile" style="display: none" v-on:change="handleFileImport()">
<div class="side-menu-wrapper" :class="{expanded: !isCollapsed}">
@@ -113,7 +112,7 @@
<span slot="title" class="item-title-root">Help</span>
</template>
<MenuItemsIterator :items="helpMenuItems" />
<MenuItemsIterator :items="helpMenuItems" :afterItemClick="trackHelpItemClick" />
<n8n-menu-item index="help-about">
<template slot="title">
@@ -151,7 +150,6 @@ import {
} from '../Interface';
import About from '@/components/About.vue';
import CredentialsList from '@/components/CredentialsList.vue';
import ExecutionsList from '@/components/ExecutionsList.vue';
import GiftNotificationIcon from './GiftNotificationIcon.vue';
import WorkflowSettings from '@/components/WorkflowSettings.vue';
@@ -168,6 +166,7 @@ import { saveAs } from 'file-saver';
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import MenuItemsIterator from './MainSidebarMenuItemsIterator.vue';
import { CREDENTIAL_LIST_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY } from '@/constants';
const helpMenuItems: IMenuItem[] = [
{
@@ -214,7 +213,6 @@ export default mixins(
name: 'MainHeader',
components: {
About,
CredentialsList,
ExecutionsList,
GiftNotificationIcon,
WorkflowSettings,
@@ -225,7 +223,6 @@ export default mixins(
aboutDialogVisible: false,
// @ts-ignore
basePath: this.$store.getters.getBaseUrl,
credentialOpenDialogVisible: false,
executionsListDialogVisible: false,
stopExecutionInProgress: false,
helpMenuItems,
@@ -293,6 +290,9 @@ export default mixins(
},
},
methods: {
trackHelpItemClick (itemType: string) {
this.$telemetry.track('User clicked help resource', { type: itemType, workflow_id: this.$store.getters.workflowId });
},
toggleCollapse () {
this.$store.commit('ui/toggleSidebarMenuCollapse');
},
@@ -306,14 +306,11 @@ export default mixins(
closeExecutionsListOpenDialog () {
this.executionsListDialogVisible = false;
},
closeCredentialOpenDialog () {
this.credentialOpenDialogVisible = false;
},
openTagManager() {
this.$store.dispatch('ui/openTagsManagerModal');
this.$store.dispatch('ui/openModal', TAGS_MANAGER_MODAL_KEY);
},
openUpdatesPanel() {
this.$store.dispatch('ui/openUpdatesPanel');
this.$store.dispatch('ui/openModal', VERSIONS_MODAL_KEY);
},
async stopExecution () {
const executionId = this.$store.getters.activeExecutionId;
@@ -361,6 +358,7 @@ export default mixins(
return;
}
this.$telemetry.track('User imported workflow', { source: 'file', workflow_id: this.$store.getters.workflowId });
this.$root.$emit('importWorkflowData', { data: worflowData });
};
@@ -371,7 +369,7 @@ export default mixins(
},
async handleSelect (key: string, keyPath: string) {
if (key === 'workflow-open') {
this.$store.dispatch('ui/openWorklfowOpenModal');
this.$store.dispatch('ui/openModal', WORKFLOW_OPEN_MODAL_KEY);
} else if (key === 'workflow-import-file') {
(this.$refs.importFile as HTMLInputElement).click();
} else if (key === 'workflow-import-url') {
@@ -423,15 +421,18 @@ export default mixins(
workflowName = workflowName.replace(/[^a-z0-9]/gi, '_');
this.$telemetry.track('User exported workflow', { workflow_id: workflowData.id });
saveAs(blob, workflowName + '.json');
} else if (key === 'workflow-save') {
this.saveCurrentWorkflow();
this.saveCurrentWorkflow(undefined);
} else if (key === 'workflow-duplicate') {
this.$store.dispatch('ui/openDuplicateModal');
this.$store.dispatch('ui/openModal', DUPLICATE_MODAL_KEY);
} else if (key === 'help-about') {
this.aboutDialogVisible = true;
this.trackHelpItemClick('about');
} else if (key === 'workflow-settings') {
this.$store.dispatch('ui/openWorkflowSettingsModal');
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);
} else if (key === 'workflow-new') {
const result = this.$store.getters.getStateIsDirty;
if(result) {
@@ -463,9 +464,9 @@ export default mixins(
}
this.$titleReset();
} else if (key === 'credentials-open') {
this.credentialOpenDialogVisible = true;
this.$store.dispatch('ui/openModal', CREDENTIAL_LIST_MODAL_KEY);
} else if (key === 'credentials-new') {
this.$store.dispatch('ui/openCredentialsSelectModal');
this.$store.dispatch('ui/openModal', CREDENTIAL_SELECT_MODAL_KEY);
} else if (key === 'execution-open-workflow') {
if (this.workflowExecution !== null) {
this.openWorkflow(this.workflowExecution.workflowId as string);

View File

@@ -22,6 +22,7 @@ export default Vue.extend({
props: [
'items',
'root',
'afterItemClick',
],
methods: {
onClick(item: IMenuItem) {
@@ -37,6 +38,10 @@ export default Vue.extend({
else {
window.location.assign(item.properties.href);
}
if(this.afterItemClick) {
this.afterItemClick(item.id);
}
}
},
},

View File

@@ -2,26 +2,37 @@
<el-dialog
:visible="visible"
:before-close="closeDialog"
:title="title"
:class="{'dialog-wrapper': true, 'center': center, 'scrollable': scrollable}"
:class="{'dialog-wrapper': true, [$style.center]: center, scrollable: scrollable}"
:width="width"
:show-close="showClose"
:custom-class="getCustomClass()"
:close-on-click-modal="closeOnClickModal"
:close-on-press-escape="closeOnPressEscape"
:style="styles"
append-to-body
>
<template v-slot:title>
<template v-slot:title v-if="$scopedSlots.header">
<slot name="header" v-if="!loading" />
</template>
<template v-slot:title v-else-if="title">
<div :class="centerTitle ? $style.centerTitle : ''">
<div v-if="title">
<n8n-heading tag="h1" size="xlarge">{{title}}</n8n-heading>
</div>
<div v-if="subtitle" :class="$style.subtitle">
<n8n-heading tag="h3" size="small" color="text-light">{{subtitle}}</n8n-heading>
</div>
</div>
</template>
<div class="modal-content" @keydown.stop @keydown.enter="handleEnter" @keydown.esc="closeDialog">
<slot v-if="!loading" name="content"/>
<div class="loader" v-else>
<div :class="$style.loader" v-else>
<n8n-spinner />
</div>
</div>
<el-row v-if="!loading" class="modal-footer">
<div v-if="!loading && $scopedSlots.footer" :class="$style.footer">
<slot name="footer" :close="closeDialog" />
</el-row>
</div>
</el-dialog>
</template>
@@ -37,6 +48,9 @@ export default Vue.extend({
title: {
type: String,
},
subtitle: {
type: String,
},
eventBus: {
type: Vue,
},
@@ -72,6 +86,9 @@ export default Vue.extend({
height: {
type: String,
},
minHeight: {
type: String,
},
maxHeight: {
type: String,
},
@@ -79,6 +96,18 @@ export default Vue.extend({
type: Boolean,
default: false,
},
centerTitle: {
type: Boolean,
default: false,
},
closeOnClickModal: {
type: Boolean,
default: true,
},
closeOnPressEscape: {
type: Boolean,
default: true,
},
},
mounted() {
window.addEventListener('keydown', this.onWindowKeydown);
@@ -151,6 +180,9 @@ export default Vue.extend({
if (this.height) {
styles['--dialog-height'] = this.height;
}
if (this.minHeight) {
styles['--dialog-min-height'] = this.minHeight;
}
if (this.maxHeight) {
styles['--dialog-max-height'] = this.maxHeight;
}
@@ -174,6 +206,7 @@ export default Vue.extend({
max-width: var(--dialog-max-width, 80%);
min-width: var(--dialog-min-width, 420px);
height: var(--dialog-height);
min-height: var(--dialog-min-height);
max-height: var(--dialog-max-height);
}
@@ -188,12 +221,14 @@ export default Vue.extend({
overflow: hidden;
flex-grow: 1;
}
}
.scrollable .modal-content {
overflow-y: auto;
&.scrollable .modal-content {
overflow-y: auto;
}
}
</style>
<style lang="scss" module>
.center {
display: flex;
align-items: center;
@@ -208,4 +243,16 @@ export default Vue.extend({
font-size: 30px;
height: 80%;
}
.centerTitle {
text-align: center;
}
.subtitle {
margin-top: var(--spacing-2xs);
}
.footer {
margin-top: var(--spacing-l);
}
</style>

View File

@@ -1,45 +1,5 @@
<template>
<div>
<ModalRoot :name="DUPLICATE_MODAL_KEY">
<template v-slot:default="{ modalName, active }">
<DuplicateWorkflowDialog
:isActive="active"
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="TAGS_MANAGER_MODAL_KEY">
<template v-slot="{ modalName }">
<TagsManager
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="WORKLOW_OPEN_MODAL_KEY">
<template v-slot="{ modalName }">
<WorkflowOpen
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="VERSIONS_MODAL_KEY" :keepAlive="true">
<template v-slot="{ modalName }">
<UpdatesPanel
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_SETTINGS_MODAL_KEY">
<template v-slot="{ modalName }">
<WorkflowSettings
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="CREDENTIAL_EDIT_MODAL_KEY">
<template v-slot="{ modalName, activeId, mode }">
<CredentialEdit
@@ -51,48 +11,83 @@
</ModalRoot>
<ModalRoot :name="CREDENTIAL_SELECT_MODAL_KEY">
<template v-slot="{ modalName }">
<CredentialsSelectModal
<CredentialsSelectModal />
</ModalRoot>
<ModalRoot :name="CREDENTIAL_LIST_MODAL_KEY">
<CredentialsList />
</ModalRoot>
<ModalRoot :name="DUPLICATE_MODAL_KEY">
<template v-slot:default="{ modalName, active }">
<DuplicateWorkflowDialog
:isActive="active"
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="PERSONALIZATION_MODAL_KEY">
<PersonalizationModal />
</ModalRoot>
<ModalRoot :name="TAGS_MANAGER_MODAL_KEY">
<TagsManager />
</ModalRoot>
<ModalRoot :name="VERSIONS_MODAL_KEY" :keepAlive="true">
<UpdatesPanel />
</ModalRoot>
<ModalRoot :name="WORKFLOW_OPEN_MODAL_KEY">
<WorkflowOpen />
</ModalRoot>
<ModalRoot :name="WORKFLOW_SETTINGS_MODAL_KEY">
<WorkflowSettings />
</ModalRoot>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import { CREDENTIAL_LIST_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, PERSONALIZATION_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import CredentialEdit from "./CredentialEdit/CredentialEdit.vue";
import DuplicateWorkflowDialog from "@/components/DuplicateWorkflowDialog.vue";
import WorkflowOpen from "@/components/WorkflowOpen.vue";
import ModalRoot from "./ModalRoot.vue";
import CredentialsList from "./CredentialsList.vue";
import CredentialsSelectModal from "./CredentialsSelectModal.vue";
import DuplicateWorkflowDialog from "./DuplicateWorkflowDialog.vue";
import ModalRoot from "./ModalRoot.vue";
import PersonalizationModal from "./PersonalizationModal.vue";
import TagsManager from "./TagsManager/TagsManager.vue";
import UpdatesPanel from "./UpdatesPanel.vue";
import WorkflowSettings from "./WorkflowSettings.vue";
import TagsManager from "@/components/TagsManager/TagsManager.vue";
import WorkflowOpen from "./WorkflowOpen.vue";
export default Vue.extend({
name: "Modals",
components: {
CredentialEdit,
CredentialsList,
CredentialsSelectModal,
DuplicateWorkflowDialog,
ModalRoot,
CredentialsSelectModal,
PersonalizationModal,
TagsManager,
UpdatesPanel,
WorkflowSettings,
TagsManager,
WorkflowOpen,
},
data: () => ({
DUPLICATE_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
WORKLOW_OPEN_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
VERSIONS_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_LIST_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
DUPLICATE_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
VERSIONS_MODAL_KEY,
WORKFLOW_OPEN_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
}),
});
</script>

View File

@@ -177,12 +177,15 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
},
disableNode () {
this.disableNodes([this.data]);
this.$telemetry.track('User set node enabled status', { node_type: this.data.type, is_enabled: !this.data.disabled, workflow_id: this.$store.getters.workflowId });
},
executeNode () {
this.$emit('runWorkflow', this.data.name, 'Node.executeNode');
},
deleteNode () {
this.$externalHooks().run('node.deleteNode', { node: this.data});
this.$telemetry.track('User deleted node', { node_type: this.data.type, workflow_id: this.$store.getters.workflowId });
Vue.nextTick(() => {
// Wait a tick else vue causes problems because the data is gone
this.$emit('removeNode', this.data.name);

View File

@@ -87,7 +87,6 @@ export default Vue.extend({
opacity: 1;
}
.subcategory + .category,
.node + .category {
margin-top: 15px;
}

View File

@@ -89,7 +89,6 @@ export default mixins(externalHooks).extend({
const nodeTypes: INodeCreateElement[] = this.searchItems;
const filter = this.searchFilter;
const returnData = nodeTypes.filter((el: INodeCreateElement) => {
const nodeType = (el.properties as INodeItemProps).nodeType;
return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter);
});
@@ -152,12 +151,24 @@ export default mixins(externalHooks).extend({
selectedType: this.selectedType,
filteredNodes: this.filteredNodeTypes,
});
this.$telemetry.trackNodesPanel('nodeCreateList.nodeFilterChanged', {
oldValue,
newValue,
selectedType: this.selectedType,
filteredNodes: this.filteredNodeTypes,
workflow_id: this.$store.getters.workflowId,
});
},
selectedType(newValue, oldValue) {
this.$externalHooks().run('nodeCreateList.selectedTypeChanged', {
oldValue,
newValue,
});
this.$telemetry.trackNodesPanel('nodeCreateList.selectedTypeChanged', {
old_filter: oldValue,
new_filter: newValue,
workflow_id: this.$store.getters.workflowId,
});
},
},
methods: {
@@ -243,6 +254,7 @@ export default mixins(externalHooks).extend({
);
} else {
this.activeCategory = [...this.activeCategory, category];
this.$telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', { category_name: category, workflow_id: this.$store.getters.workflowId });
}
this.activeIndex = this.categorized.findIndex(
@@ -252,6 +264,7 @@ export default mixins(externalHooks).extend({
onSubcategorySelected(selected: INodeCreateElement) {
this.activeSubcategoryIndex = 0;
this.activeSubcategory = selected;
this.$telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', { selected, workflow_id: this.$store.getters.workflowId });
},
onSubcategoryClose() {
@@ -273,6 +286,7 @@ export default mixins(externalHooks).extend({
},
async destroyed() {
this.$externalHooks().run('nodeCreateList.destroyed');
this.$telemetry.trackNodesPanel('nodeCreateList.destroyed', { workflow_id: this.$store.getters.workflowId });
},
});
</script>

View File

@@ -35,7 +35,7 @@
<script lang="ts">
import { HTTP_REQUEST_NODE_NAME, REQUEST_NODE_FORM_URL, WEBHOOK_NODE_NAME } from '@/constants';
import { HTTP_REQUEST_NODE_TYPE, REQUEST_NODE_FORM_URL, WEBHOOK_NODE_TYPE } from '@/constants';
import Vue from 'vue';
import NoResultsIcon from './NoResultsIcon.vue';
@@ -57,11 +57,11 @@ export default Vue.extend({
},
methods: {
selectWebhook() {
this.$emit('nodeTypeSelected', WEBHOOK_NODE_NAME);
this.$emit('nodeTypeSelected', WEBHOOK_NODE_TYPE);
},
selectHttpRequest() {
this.$emit('nodeTypeSelected', HTTP_REQUEST_NODE_NAME);
this.$emit('nodeTypeSelected', HTTP_REQUEST_NODE_TYPE);
},
},
});

View File

@@ -19,6 +19,7 @@ import { HIDDEN_NODES } from '@/constants';
import MainPanel from './MainPanel.vue';
import { getCategoriesWithNodes, getCategorizedList } from './helpers';
import { mapGetters } from 'vuex';
export default Vue.extend({
name: 'NodeCreator',
@@ -35,6 +36,7 @@ export default Vue.extend({
};
},
computed: {
...mapGetters('settings', ['personalizedNodeTypes']),
nodeTypes(): INodeTypeDescription[] {
return this.$store.getters.allNodeTypes;
},
@@ -57,7 +59,7 @@ export default Vue.extend({
}, []);
},
categoriesWithNodes(): ICategoriesWithNodes {
return getCategoriesWithNodes(this.visibleNodeTypes);
return getCategoriesWithNodes(this.visibleNodeTypes, this.personalizedNodeTypes as string[]);
},
categorizedItems(): INodeCreateElement[] {
return getCategorizedList(this.categoriesWithNodes);

View File

@@ -1,25 +1,51 @@
import { CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, SUBCATEGORY_DESCRIPTIONS, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER, ALL_NODE_FILTER } from '@/constants';
import { CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, SUBCATEGORY_DESCRIPTIONS, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER, ALL_NODE_FILTER, PERSONALIZED_CATEGORY } from '@/constants';
import { INodeCreateElement, ICategoriesWithNodes, INodeItemProps } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
const addNodeToCategory = (accu: ICategoriesWithNodes, nodeType: INodeTypeDescription, category: string, subcategory: string) => {
if (!accu[category]) {
accu[category] = {};
}
if (!accu[category][subcategory]) {
accu[category][subcategory] = {
triggerCount: 0,
regularCount: 0,
nodes: [],
};
}
const isTrigger = nodeType.group.includes('trigger');
if (isTrigger) {
accu[category][subcategory].triggerCount++;
}
if (!isTrigger) {
accu[category][subcategory].regularCount++;
}
accu[category][subcategory].nodes.push({
type: 'node',
key: `${category}_${nodeType.name}`,
category,
properties: {
nodeType,
subcategory,
},
includedByTrigger: isTrigger,
includedByRegular: !isTrigger,
});
};
export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[]): ICategoriesWithNodes => {
return nodeTypes.reduce(
export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[], personalizedNodeTypes: string[]): ICategoriesWithNodes => {
const sorted = [...nodeTypes].sort((a: INodeTypeDescription, b: INodeTypeDescription) => a.displayName > b.displayName? 1 : -1);
return sorted.reduce(
(accu: ICategoriesWithNodes, nodeType: INodeTypeDescription) => {
if (personalizedNodeTypes.includes(nodeType.name)) {
addNodeToCategory(accu, nodeType, PERSONALIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY);
}
if (!nodeType.codex || !nodeType.codex.categories) {
accu[UNCATEGORIZED_CATEGORY][UNCATEGORIZED_SUBCATEGORY].nodes.push({
type: 'node',
category: UNCATEGORIZED_CATEGORY,
key: `${UNCATEGORIZED_CATEGORY}_${nodeType.name}`,
properties: {
subcategory: UNCATEGORIZED_SUBCATEGORY,
nodeType,
},
includedByTrigger: nodeType.group.includes('trigger'),
includedByRegular: !nodeType.group.includes('trigger'),
});
addNodeToCategory(accu, nodeType, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY);
return accu;
}
nodeType.codex.categories.forEach((_category: string) => {
const category = _category.trim();
const subcategory =
@@ -28,58 +54,25 @@ export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[]): ICate
nodeType.codex.subcategories[category]
? nodeType.codex.subcategories[category][0]
: UNCATEGORIZED_SUBCATEGORY;
if (!accu[category]) {
accu[category] = {};
}
if (!accu[category][subcategory]) {
accu[category][subcategory] = {
triggerCount: 0,
regularCount: 0,
nodes: [],
};
}
const isTrigger = nodeType.group.includes('trigger');
if (isTrigger) {
accu[category][subcategory].triggerCount++;
}
if (!isTrigger) {
accu[category][subcategory].regularCount++;
}
accu[category][subcategory].nodes.push({
type: 'node',
key: `${category}_${nodeType.name}`,
category,
properties: {
nodeType,
subcategory,
},
includedByTrigger: isTrigger,
includedByRegular: !isTrigger,
});
addNodeToCategory(accu, nodeType, category, subcategory);
});
return accu;
},
{
[UNCATEGORIZED_CATEGORY]: {
[UNCATEGORIZED_SUBCATEGORY]: {
triggerCount: 0,
regularCount: 0,
nodes: [],
},
},
},
{},
);
};
const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => {
const excludeFromSort = [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, UNCATEGORIZED_CATEGORY, PERSONALIZED_CATEGORY];
const categories = Object.keys(categoriesWithNodes);
const sorted = categories.filter(
(category: string) =>
category !== CORE_NODES_CATEGORY && category !== CUSTOM_NODES_CATEGORY && category !== UNCATEGORIZED_CATEGORY,
!excludeFromSort.includes(category),
);
sorted.sort();
return [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, ...sorted, UNCATEGORIZED_CATEGORY];
return [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, PERSONALIZED_CATEGORY, ...sorted, UNCATEGORIZED_CATEGORY];
};
export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes): INodeCreateElement[] => {
@@ -173,4 +166,4 @@ export const matchesNodeType = (el: INodeCreateElement, filter: string) => {
const nodeType = (el.properties as INodeItemProps).nodeType;
return nodeType.displayName.toLowerCase().indexOf(filter) !== -1 || matchesAlias(nodeType, filter);
};
};

View File

@@ -191,17 +191,19 @@ export default mixins(
},
onCredentialSelected (credentialType: string, credentialId: string | null | undefined) {
let selected = undefined;
if (credentialId === NEW_CREDENTIALS_TEXT) {
this.listenForNewCredentials(credentialType);
this.$store.dispatch('ui/openNewCredential', { type: credentialType });
this.$telemetry.track('User opened Credential modal', { credential_type: credentialType, source: 'node', new_credential: true, workflow_id: this.$store.getters.workflowId });
return;
}
this.$telemetry.track('User selected credential from node modal', { credential_type: credentialType, workflow_id: this.$store.getters.workflowId });
const selectedCredentials = this.$store.getters['credentials/getCredentialById'](credentialId);
const oldCredentials = this.node.credentials && this.node.credentials[credentialType] ? this.node.credentials[credentialType] : {};
selected = { id: selectedCredentials.id, name: selectedCredentials.name };
const selected = { id: selectedCredentials.id, name: selectedCredentials.name };
// if credentials has been string or neither id matched nor name matched uniquely
if (oldCredentials.id === null || (oldCredentials.id && !this.$store.getters['credentials/getCredentialByIdAndType'](oldCredentials.id, credentialType))) {
@@ -272,6 +274,8 @@ export default mixins(
const { id } = this.node.credentials[credentialType];
this.$store.dispatch('ui/openExisitngCredential', { id });
this.$telemetry.track('User opened Credential modal', { credential_type: credentialType, source: 'node', new_credential: false, workflow_id: this.$store.getters.workflowId });
this.listenForNewCredentials(credentialType);
},
},

View File

@@ -16,7 +16,7 @@
The node is not valid as its type "{{node.type}}" is unknown.
</div>
<div class="node-parameters-wrapper" v-if="node && nodeValid">
<el-tabs stretch>
<el-tabs stretch @tab-click="handleTabClick">
<el-tab-pane label="Parameters">
<node-credentials :node="node" @credentialSelected="credentialSelected"></node-credentials>
<node-webhooks :node="node" :nodeType="nodeType" />
@@ -49,6 +49,8 @@ import {
IUpdateInformation,
} from '@/Interface';
import { ElTabPane } from "element-ui/types/tab-pane";
import DisplayWithChange from '@/components/DisplayWithChange.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ParameterInputList from '@/components/ParameterInputList.vue';
@@ -501,6 +503,11 @@ export default mixins(
this.nodeValid = false;
}
},
handleTabClick(tab: ElTabPane) {
if(tab.label === 'Settings') {
this.$telemetry.track('User viewed node settings', { node_type: this.node ? this.node.type : '', workflow_id: this.$store.getters.workflowId });
}
},
},
mounted () {
this.setNodeValues();

View File

@@ -44,7 +44,7 @@ import {
NodeHelpers,
} from 'n8n-workflow';
import { WEBHOOK_NODE_NAME } from '@/constants';
import { WEBHOOK_NODE_TYPE } from '@/constants';
import { copyPaste } from '@/components/mixins/copyPaste';
import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
@@ -64,7 +64,7 @@ export default mixins(
],
data () {
return {
isMinimized: this.nodeType.name !== WEBHOOK_NODE_NAME,
isMinimized: this.nodeType.name !== WEBHOOK_NODE_TYPE,
showUrlFor: 'test',
};
},
@@ -119,7 +119,7 @@ export default mixins(
},
watch: {
node () {
this.isMinimized = this.nodeType.name !== WEBHOOK_NODE_NAME;
this.isMinimized = this.nodeType.name !== WEBHOOK_NODE_TYPE;
},
},
});

View File

@@ -585,6 +585,21 @@ export default mixins(
closeExpressionEditDialog () {
this.expressionEditDialogVisible = false;
},
trackExpressionEditOpen () {
if(!this.node) {
return;
}
if((this.node.type as string).startsWith('n8n-nodes-base')) {
this.$telemetry.track('User opened Expression Editor', {
node_type: this.node.type,
parameter_name: this.parameter.displayName,
parameter_field_type: this.parameter.type,
new_expression: !this.isValueExpression,
workflow_id: this.$store.getters.workflowId,
});
}
},
closeTextEditDialog () {
this.textEditDialogVisible = false;
},
@@ -612,6 +627,7 @@ export default mixins(
openExpressionEdit() {
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();
return;
}
},
@@ -621,6 +637,7 @@ export default mixins(
setFocus () {
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();
return;
}
@@ -700,6 +717,7 @@ export default mixins(
}
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();
} else if (command === 'removeExpression') {
this.valueChanged(this.expressionValueComputed !== undefined ? this.expressionValueComputed : null);
} else if (command === 'refreshOptions') {

View File

@@ -19,7 +19,7 @@
inputSize="large"
/>
<div class="errors" v-if="showRequiredErrors">
This field is required. <a v-if="documentationUrl" :href="documentationUrl" target="_blank">Open docs</a>
This field is required. <a v-if="documentationUrl" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open docs</a>
</div>
</n8n-input-label>
</template>
@@ -77,6 +77,13 @@ export default Vue.extend({
valueChanged(parameterData: IUpdateInformation) {
this.$emit('change', parameterData);
},
onDocumentationUrlClick (): void {
this.$telemetry.track('User clicked credential modal docs link', {
docs_link: this.documentationUrl,
source: 'field',
workflow_id: this.$store.getters.workflowId,
});
},
},
});
</script>

View File

@@ -0,0 +1,249 @@
<template>
<Modal
:name="PERSONALIZATION_MODAL_KEY"
:title="!submitted? 'Get started' : 'Thanks!'"
:subtitle="!submitted? 'These questions help us tailor n8n to you' : ''"
:centerTitle="true"
:showClose="false"
:eventBus="modalBus"
:closeOnClickModal="false"
:closeOnPressEscape="false"
width="460px"
@enter="save"
@input="onInput"
>
<template v-slot:content>
<div v-if="submitted" :class="$style.submittedContainer">
<img :class="$style.demoImage" :src="baseUrl + 'suggestednodes.png'" />
<n8n-text>Look out for things marked with a . They are personalized to make n8n more relevant to you.</n8n-text>
</div>
<div :class="$style.container" v-else>
<n8n-input-label label="Which of these areas do you mainly work in?">
<n8n-select :value="values[WORK_AREA_KEY]" placeholder="Select..." @change="(value) => onInput(WORK_AREA_KEY, value)">
<n8n-option :value="AUTOMATION_CONSULTING_WORK_AREA" label="Automation consulting" />
<n8n-option :value="FINANCE_WORK_AREA" label="Finance" />
<n8n-option :value="HR_WORK_AREA" label="HR" />
<n8n-option :value="IT_ENGINEERING_WORK_AREA" label="IT / Engineering" />
<n8n-option :value="LEGAL_WORK_AREA" label="Legal" />
<n8n-option :value="MARKETING_WORK_AREA" label="Marketing / Growth" />
<n8n-option :value="OPS_WORK_AREA" label="Operations" />
<n8n-option :value="PRODUCT_WORK_AREA" label="Product" />
<n8n-option :value="SALES_BUSINESSDEV_WORK_AREA" label="Sales / Business Development" />
<n8n-option :value="SECURITY_WORK_AREA" label="Security" />
<n8n-option :value="SUPPORT_WORK_AREA" label="Support" />
<n8n-option :value="OTHER_WORK_AREA_OPTION" label="Other (please specify)" />
</n8n-select>
</n8n-input-label>
<n8n-input
v-if="otherWorkAreaFieldVisible"
:value="values[OTHER_WORK_AREA_KEY]"
placeholder="Specify your work area"
@input="(value) => onInput(OTHER_WORK_AREA_KEY, value)"
/>
<n8n-input-label label="How are your coding skills?">
<n8n-select :value="values[CODING_SKILL_KEY]" placeholder="Select..." @change="(value) => onInput(CODING_SKILL_KEY, value)">
<n8n-option
label="0 (Never coded)"
value="0"
/>
<n8n-option
label="1"
value="1"
/>
<n8n-option
label="2"
value="2"
/>
<n8n-option
label="3"
value="3"
/>
<n8n-option
label="4"
value="4"
/>
<n8n-option
label="5 (Pro coder)"
value="5"
/>
</n8n-select>
</n8n-input-label>
<n8n-input-label label="How big is your company?">
<n8n-select :value="values[COMPANY_SIZE_KEY]" placeholder="Select..." @change="(value) => onInput(COMPANY_SIZE_KEY, value)">
<n8n-option
label="Less than 20 people"
:value="COMPANY_SIZE_20_OR_LESS"
/>
<n8n-option
label="20-99 people"
:value="COMPANY_SIZE_20_99"
/>
<n8n-option
label="100-499 people"
:value="COMPANY_SIZE_100_499"
/>
<n8n-option
label="500-999 people"
:value="COMPANY_SIZE_500_999"
/>
<n8n-option
label="1000+ people"
:value="COMPANY_SIZE_1000_OR_MORE"
/>
<n8n-option
label="I'm not using n8n for work"
:value="COMPANY_SIZE_PERSONAL_USE"
/>
</n8n-select>
</n8n-input-label>
</div>
</template>
<template v-slot:footer>
<div>
<n8n-button v-if="submitted" @click="closeDialog" label="Get started" float="right" />
<n8n-button v-else @click="save" :loading="isSaving" label="Continue" float="right" />
</div>
</template>
</Modal>
</template>
<script lang="ts">
import mixins from "vue-typed-mixins";
import {
PERSONALIZATION_MODAL_KEY,
AUTOMATION_CONSULTING_WORK_AREA,
FINANCE_WORK_AREA,
HR_WORK_AREA,
IT_ENGINEERING_WORK_AREA,
LEGAL_WORK_AREA,
MARKETING_WORK_AREA,
PRODUCT_WORK_AREA,
SALES_BUSINESSDEV_WORK_AREA,
SECURITY_WORK_AREA,
SUPPORT_WORK_AREA,
OPS_WORK_AREA,
OTHER_WORK_AREA_OPTION,
COMPANY_SIZE_20_OR_LESS,
COMPANY_SIZE_20_99,
COMPANY_SIZE_100_499,
COMPANY_SIZE_500_999,
COMPANY_SIZE_1000_OR_MORE,
COMPANY_SIZE_PERSONAL_USE,
WORK_AREA_KEY,
COMPANY_SIZE_KEY,
CODING_SKILL_KEY,
OTHER_WORK_AREA_KEY,
} from "../constants";
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
import { showMessage } from "@/components/mixins/showMessage";
import Modal from "./Modal.vue";
import { IPersonalizationSurveyAnswers, IPersonalizationSurveyKeys } from "@/Interface";
import Vue from "vue";
import { mapGetters } from "vuex";
export default mixins(showMessage, workflowHelpers).extend({
components: { Modal },
name: "PersonalizationModal",
data() {
return {
submitted: false,
isSaving: false,
PERSONALIZATION_MODAL_KEY,
otherWorkAreaFieldVisible: false,
modalBus: new Vue(),
values: {
[WORK_AREA_KEY]: null,
[COMPANY_SIZE_KEY]: null,
[CODING_SKILL_KEY]: null,
[OTHER_WORK_AREA_KEY]: null,
} as IPersonalizationSurveyAnswers,
AUTOMATION_CONSULTING_WORK_AREA,
FINANCE_WORK_AREA,
HR_WORK_AREA,
IT_ENGINEERING_WORK_AREA,
LEGAL_WORK_AREA,
MARKETING_WORK_AREA,
PRODUCT_WORK_AREA,
SALES_BUSINESSDEV_WORK_AREA,
SECURITY_WORK_AREA,
SUPPORT_WORK_AREA,
OPS_WORK_AREA,
OTHER_WORK_AREA_OPTION,
COMPANY_SIZE_20_OR_LESS,
COMPANY_SIZE_20_99,
COMPANY_SIZE_100_499,
COMPANY_SIZE_500_999,
COMPANY_SIZE_1000_OR_MORE,
COMPANY_SIZE_PERSONAL_USE,
WORK_AREA_KEY,
COMPANY_SIZE_KEY,
CODING_SKILL_KEY,
OTHER_WORK_AREA_KEY,
};
},
computed: {
...mapGetters({
baseUrl: 'getBaseUrl',
}),
},
methods: {
closeDialog() {
this.modalBus.$emit('close');
},
onInput(name: IPersonalizationSurveyKeys, value: string) {
if (name === WORK_AREA_KEY && value === OTHER_WORK_AREA_OPTION) {
this.otherWorkAreaFieldVisible = true;
}
else if (name === WORK_AREA_KEY) {
this.otherWorkAreaFieldVisible = false;
this.values[OTHER_WORK_AREA_KEY] = null;
}
this.values[name] = value;
},
async save(): Promise<void> {
this.$data.isSaving = true;
try {
await this.$store.dispatch('settings/submitPersonalizationSurvey', this.values);
if (this.values[WORK_AREA_KEY] === null && this.values[COMPANY_SIZE_KEY] === null && this.values[CODING_SKILL_KEY] === null) {
this.closeDialog();
}
this.submitted = true;
} catch (e) {
this.$showError(e, 'Error while submitting results');
}
this.$data.isSaving = false;
},
},
});
</script>
<style lang="scss" module>
.container {
> div:not(:last-child) {
margin-bottom: var(--spacing-m);
}
}
.submittedContainer {
* {
margin-bottom: var(--spacing-2xs);
}
}
.demoImage {
border-radius: var(--border-radius-large);
border: var(--border-base);
width: 100%;
height: 140px;
}
</style>

View File

@@ -630,6 +630,10 @@ export default mixins(
displayMode (newValue, oldValue) {
this.closeBinaryDataDisplay();
this.$externalHooks().run('runData.displayModeChanged', { newValue, oldValue });
if(this.node) {
const nodeType = this.node ? this.node.type : '';
this.$telemetry.track('User changed node output view mode', { old_mode: oldValue, new_mode: newValue, node_type: nodeType, workflow_id: this.$store.getters.workflowId });
}
},
maxRunIndex () {
this.runIndex = Math.min(this.runIndex, this.maxRunIndex);

View File

@@ -55,7 +55,7 @@ import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { ITag } from "@/Interface";
import { MAX_TAG_NAME_LENGTH } from "@/constants";
import { MAX_TAG_NAME_LENGTH, TAGS_MANAGER_MODAL_KEY } from "@/constants";
import { showMessage } from "@/components/mixins/showMessage";
@@ -150,7 +150,7 @@ export default mixins(showMessage).extend({
);
if (ops === MANAGE_KEY) {
this.$data.filter = "";
this.$store.dispatch("ui/openTagsManagerModal");
this.$store.dispatch("ui/openModal", TAGS_MANAGER_MODAL_KEY);
} else if (ops === CREATE_KEY) {
this.onCreate();
} else {

View File

@@ -30,7 +30,6 @@ $--footer-spacing: 45px;
display: flex;
justify-content: center;
align-items: center;
min-height: $--tags-manager-min-height - $--footer-spacing;
margin-top: $--footer-spacing;
}

View File

@@ -1,10 +1,11 @@
<template>
<Modal
title="Manage tags"
:name="modalName"
:name="TAGS_MANAGER_MODAL_KEY"
:eventBus="modalBus"
@enter="onEnter"
minWidth="620px"
minHeight="420px"
>
<template v-slot:content>
<el-row>
@@ -40,13 +41,13 @@ import { showMessage } from "@/components/mixins/showMessage";
import TagsView from "@/components/TagsManager/TagsView/TagsView.vue";
import NoTagsView from "@/components/TagsManager/NoTagsView.vue";
import Modal from "@/components/Modal.vue";
import { TAGS_MANAGER_MODAL_KEY } from '../../constants';
export default mixins(showMessage).extend({
name: "TagsManager",
created() {
this.$store.dispatch("tags/fetchAll", {force: true, withUsageCount: true});
},
props: ['modalName'],
data() {
const tagIds = (this.$store.getters['tags/allTags'] as ITag[])
.map((tag) => tag.id);
@@ -55,6 +56,7 @@ export default mixins(showMessage).extend({
tagIds,
isCreating: false,
modalBus: new Vue(),
TAGS_MANAGER_MODAL_KEY,
};
},
components: {
@@ -183,9 +185,3 @@ export default mixins(showMessage).extend({
});
</script>
<style lang="scss" scoped>
.el-row {
min-height: $--tags-manager-min-height;
margin-bottom: 15px;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<fragment></fragment>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapGetters } from 'vuex';
export default Vue.extend({
name: 'Telemetry',
computed: {
...mapGetters(['telemetry']),
},
watch: {
telemetry(opts) {
if (opts.enabled) {
this.$telemetry.init(opts, this.$store.getters.instanceId);
}
},
},
});
</script>

View File

@@ -1,6 +1,6 @@
<template>
<ModalDrawer
:name="modalName"
:name="VERSIONS_MODAL_KEY"
direction="ltr"
width="520px"
>
@@ -48,6 +48,7 @@ import { mapGetters } from 'vuex';
import ModalDrawer from './ModalDrawer.vue';
import TimeAgo from './TimeAgo.vue';
import VersionCard from './VersionCard.vue';
import { VERSIONS_MODAL_KEY } from '../constants';
export default Vue.extend({
name: 'UpdatesPanel',
@@ -56,10 +57,14 @@ export default Vue.extend({
VersionCard,
TimeAgo,
},
props: ['modalName'],
computed: {
...mapGetters('versions', ['nextVersions', 'currentVersion', 'infoUrl']),
},
data() {
return {
VERSIONS_MODAL_KEY,
};
},
});
</script>

View File

@@ -144,6 +144,7 @@ export default mixins(
}
this.$externalHooks().run(activationEventName, { workflowId: this.workflowId, active: newActiveState });
this.$telemetry.track('User set workflow active status', { workflow_id: this.workflowId, is_active: newActiveState });
this.$emit('workflowActiveChanged', { id: this.workflowId, active: newActiveState });
this.loading = false;

View File

@@ -1,15 +1,15 @@
<template>
<Modal
:name="modalName"
:name="WORKFLOW_OPEN_MODAL_KEY"
width="80%"
minWidth="620px"
:classic="true"
>
<template v-slot:header>
<div class="workflows-header">
<div class="title">
<h1>Open Workflow</h1>
</div>
<n8n-heading tag="h1" size="xlarge" class="title">
Open Workflow
</n8n-heading>
<div class="tags-filter">
<TagsDropdown
placeholder="Filter by tags..."
@@ -66,6 +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';
export default mixins(
genericHelpers,
@@ -88,6 +89,7 @@ export default mixins(
workflows: [] as IWorkflowShortResponse[],
filterTagIds: [] as string[],
prevFilterTagIds: [] as string[],
WORKFLOW_OPEN_MODAL_KEY,
};
},
computed: {
@@ -214,13 +216,8 @@ export default mixins(
.workflows-header {
display: flex;
.title {
> *:first-child {
flex-grow: 1;
h1 {
line-height: 24px;
font-size: 18px;
}
}
.search-filter {

View File

@@ -1,6 +1,6 @@
<template>
<Modal
:name="modalName"
:name="WORKFLOW_SETTINGS_MODAL_KEY"
width="65%"
maxHeight="80%"
title="Workflow Settings"
@@ -167,7 +167,7 @@
</template>
<template v-slot:footer>
<div class="action-buttons">
<n8n-button label="Save" size="large" @click="saveSettings" />
<n8n-button label="Save" size="large" float="right" @click="saveSettings" />
</div>
</template>
</Modal>
@@ -187,6 +187,7 @@ import {
IWorkflowShortResponse,
} from '@/Interface';
import Modal from './Modal.vue';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '../constants';
import mixins from 'vue-typed-mixins';
@@ -197,11 +198,6 @@ export default mixins(
showMessage,
).extend({
name: 'WorkflowSettings',
props: {
modalName: {
type: String,
},
},
components: {
Modal,
},
@@ -236,6 +232,7 @@ export default mixins(
maxExecutionTimeout: this.$store.getters.maxExecutionTimeout,
timeoutHMS: { hours: 0, minutes: 0, seconds: 0 } as ITimeoutHMS,
modalBus: new Vue(),
WORKFLOW_SETTINGS_MODAL_KEY,
};
},
async mounted () {
@@ -299,10 +296,12 @@ export default mixins(
this.isLoading = false;
this.$externalHooks().run('workflowSettings.dialogVisibleChanged', { dialogVisible: true });
this.$telemetry.track('User opened workflow settings', { workflow_id: this.$store.getters.workflowId });
},
methods: {
closeDialog () {
this.modalBus.$emit('close');
this.$externalHooks().run('workflowSettings.dialogVisibleChanged', { dialogVisible: false });
},
setTimeout (key: string, value: string) {
const time = value ? parseInt(value, 10) : 0;
@@ -488,6 +487,7 @@ export default mixins(
this.closeDialog();
this.$externalHooks().run('workflowSettings.saveSettings', { oldSettings });
this.$telemetry.track('User updated workflow settings', { workflow_id: this.$store.getters.workflowId });
},
toggleTimeout() {
this.workflowSettings.executionTimeout = this.workflowSettings.executionTimeout === -1 ? 0 : -1;
@@ -514,17 +514,13 @@ export default mixins(
}
}
.action-buttons {
margin-top: 1em;
text-align: right;
}
.setting-info {
display: none;
}
.setting-name {
line-height: 32px;
font-weight: var(--font-weight-regular);
}
.setting-name:hover {

View File

@@ -3,6 +3,7 @@ import { showMessage } from './showMessage';
import {
IVersion,
} from '../../Interface';
import { VERSIONS_MODAL_KEY } from '@/constants';
export const newVersions = mixins(
showMessage,
@@ -30,7 +31,7 @@ export const newVersions = mixins(
title: 'Critical update available',
message,
onClick: () => {
this.$store.dispatch('ui/openUpdatesPanel');
this.$store.dispatch('ui/openModal', VERSIONS_MODAL_KEY);
},
closeOnClick: true,
customClass: 'clickable',

View File

@@ -4,7 +4,7 @@ import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { nodeIndex } from '@/components/mixins/nodeIndex';
import { NODE_NAME_PREFIX } from '@/constants';
import { NODE_NAME_PREFIX, NO_OP_NODE_TYPE } from '@/constants';
export const nodeBase = mixins(
deviceSupportHelpers,
@@ -96,7 +96,7 @@ export const nodeBase = mixins(
if (!nodeTypeData) {
// If node type is not know use by default the base.noOp data to display it
nodeTypeData = this.$store.getters.nodeType('n8n-nodes-base.noOp');
nodeTypeData = this.$store.getters.nodeType(NO_OP_NODE_TYPE);
}
const anchorPositions: {

View File

@@ -16,6 +16,7 @@ import { titleChange } from '@/components/mixins/titleChange';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
export const pushConnection = mixins(
externalHooks,
@@ -246,7 +247,7 @@ export const pushConnection = mixins(
if (this.$store.getters.isNewWorkflow) {
await this.saveAsNewWorkflow();
}
this.$store.dispatch('ui/openWorkflowSettingsModal');
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);
}
},
});

View File

@@ -11,7 +11,6 @@ import {
IExecutionFlattedResponse,
IExecutionsListResponse,
IExecutionsStopData,
IN8nUISettings,
IStartRunData,
IWorkflowDb,
IWorkflowShortResponse,
@@ -78,9 +77,6 @@ export const restApi = Vue.extend({
stopCurrentExecution: (executionId: string): Promise<IExecutionsStopData> => {
return self.restApi().makeRestApiRequest('POST', `/executions-current/${executionId}/stop`);
},
getSettings: (): Promise<IN8nUISettings> => {
return self.restApi().makeRestApiRequest('GET', `/settings`);
},
// Returns all node-types
getNodeTypes: (onlyLatest = false): Promise<INodeTypeDescription[]> => {

View File

@@ -12,7 +12,7 @@ let stickyNotificationQueue: ElNotificationComponent[] = [];
export const showMessage = mixins(externalHooks).extend({
methods: {
$showMessage(messageData: ElNotificationOptions) {
$showMessage(messageData: ElNotificationOptions, track = true) {
messageData.dangerouslyUseHTMLString = true;
if (messageData.position === undefined) {
messageData.position = 'bottom-right';
@@ -24,6 +24,10 @@ export const showMessage = mixins(externalHooks).extend({
stickyNotificationQueue.push(notification);
}
if(messageData.type === 'error' && track) {
this.$telemetry.track('Instance FE emitted error', { error_title: messageData.title, error_message: messageData.message, workflow_id: this.$store.getters.workflowId });
}
return notification;
},
@@ -116,13 +120,14 @@ export const showMessage = mixins(externalHooks).extend({
${this.collapsableDetails(error)}`,
type: 'error',
duration: 0,
});
}, false);
this.$externalHooks().run('showMessage.showError', {
title,
message,
errorMessage: error.message,
});
this.$telemetry.track('Instance FE emitted error', { error_title: title, error_description: message, error_message: error.message, workflow_id: this.$store.getters.workflowId });
},
async confirmMessage (message: string, headline: string, type: MessageType | null = 'warning', confirmButtonText = 'OK', cancelButtonText = 'Cancel'): Promise<boolean> {

View File

@@ -1,9 +1,9 @@
import {
ERROR_TRIGGER_NODE_NAME,
ERROR_TRIGGER_NODE_TYPE,
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
START_NODE_TYPE,
WEBHOOK_NODE_NAME,
WEBHOOK_NODE_TYPE,
} from '@/constants';
import {
@@ -24,6 +24,7 @@ import {
IRunExecutionData,
IWorfklowIssues,
IWorkflowDataProxyAdditionalKeys,
TelemetryHelpers,
Workflow,
NodeHelpers,
} from 'n8n-workflow';
@@ -162,7 +163,7 @@ export const workflowHelpers = mixins(
// if there are any
let checkWebhook: string[] = [];
for (const nodeName of Object.keys(workflow.nodes)) {
if (workflow.nodes[nodeName].disabled !== true && workflow.nodes[nodeName].type === WEBHOOK_NODE_NAME) {
if (workflow.nodes[nodeName].disabled !== true && workflow.nodes[nodeName].type === WEBHOOK_NODE_TYPE) {
checkWebhook = [nodeName, ...checkWebhook, ...workflow.getChildNodes(nodeName)];
}
}
@@ -246,7 +247,7 @@ export const workflowHelpers = mixins(
// As we do not have the trigger/poll functions available in the frontend
// we use the information available to figure out what are trigger nodes
// @ts-ignore
trigger: ![ERROR_TRIGGER_NODE_NAME, START_NODE_TYPE].includes(nodeType) && nodeTypeDescription.inputs.length === 0 && !nodeTypeDescription.webhooks || undefined,
trigger: ![ERROR_TRIGGER_NODE_TYPE, START_NODE_TYPE].includes(nodeType) && nodeTypeDescription.inputs.length === 0 && !nodeTypeDescription.webhooks || undefined,
};
},
};

View File

@@ -56,11 +56,18 @@ export const workflowRun = mixins(
return response;
},
async runWorkflow (nodeName?: string, source?: string): Promise<IExecutionPushResponse | undefined> {
const workflow = this.getWorkflow();
if(nodeName) {
this.$telemetry.track('User clicked execute node button', { node_type: nodeName, workflow_id: this.$store.getters.workflowId });
} else {
this.$telemetry.track('User clicked execute workflow button', { workflow_id: this.$store.getters.workflowId });
}
if (this.$store.getters.isActionActive('workflowRunning') === true) {
return;
}
const workflow = this.getWorkflow();
this.$titleSet(workflow.name as string, 'EXECUTING');
this.clearAllStickyNotifications();

View File

@@ -18,11 +18,13 @@ export const MAX_TAG_NAME_LENGTH = 24;
// modals
export const DUPLICATE_MODAL_KEY = 'duplicate';
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
export const WORKLOW_OPEN_MODAL_KEY = 'workflowOpen';
export const WORKFLOW_OPEN_MODAL_KEY = 'workflowOpen';
export const VERSIONS_MODAL_KEY = 'versions';
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
export const CREDENTIAL_EDIT_MODAL_KEY = 'editCredential';
export const CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential';
export const CREDENTIAL_LIST_MODAL_KEY = 'credentialsList';
export const PERSONALIZATION_MODAL_KEY = 'personalization';
// breakpoints
export const BREAKPOINT_SM = 768;
@@ -33,7 +35,35 @@ export const BREAKPOINT_XL = 1920;
// templates
export const TEMPLATES_BASE_URL = `https://api.n8n.io/`;
// node types
export const CALENDLY_TRIGGER_NODE_TYPE = 'n8n-nodes-base.calendlyTrigger';
export const CRON_NODE_TYPE = 'n8n-nodes-base.cron';
export const CLEARBIT_NODE_TYPE = 'n8n-nodes-base.clearbit';
export const FUNCTION_NODE_TYPE = 'n8n-nodes-base.function';
export const GITHUB_TRIGGER_NODE_TYPE = 'n8n-nodes-base.githubTrigger';
export const ERROR_TRIGGER_NODE_TYPE = 'n8n-nodes-base.errorTrigger';
export const ELASTIC_SECURITY_NODE_TYPE = 'n8n-nodes-base.elasticSecurity';
export const EMAIL_SEND_NODE_TYPE = 'n8n-nodes-base.emailSend';
export const EXECUTE_COMMAND_NODE_TYPE = 'n8n-nodes-base.executeCommand';
export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest';
export const IF_NODE_TYPE = 'n8n-nodes-base.if';
export const ITEM_LISTS_NODE_TYPE = 'n8n-nodes-base.itemLists';
export const JIRA_TRIGGER_NODE_TYPE = 'n8n-nodes-base.jiraTrigger';
export const MICROSOFT_EXCEL_NODE_TYPE = 'n8n-nodes-base.microsoftExcel';
export const MICROSOFT_TEAMS_NODE_TYPE = 'n8n-nodes-base.microsoftTeams';
export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp';
export const PAGERDUTY_NODE_TYPE = 'n8n-nodes-base.pagerDuty';
export const SALESFORCE_NODE_TYPE = 'n8n-nodes-base.salesforce';
export const SEGMENT_NODE_TYPE = 'n8n-nodes-base.segment';
export const SET_NODE_TYPE = 'n8n-nodes-base.set';
export const SLACK_NODE_TYPE = 'n8n-nodes-base.slack';
export const SPREADSHEET_FILE_NODE_TYPE = 'n8n-nodes-base.spreadsheetFile';
export const START_NODE_TYPE = 'n8n-nodes-base.start';
export const SWITCH_NODE_TYPE = 'n8n-nodes-base.switch';
export const QUICKBOOKS_NODE_TYPE = 'n8n-nodes-base.quickbooks';
export const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook';
export const XERO_NODE_TYPE = 'n8n-nodes-base.xero';
// Node creator
export const CORE_NODES_CATEGORY = 'Core Nodes';
@@ -53,12 +83,36 @@ export const TRIGGER_NODE_FILTER = 'Trigger';
export const ALL_NODE_FILTER = 'All';
export const UNCATEGORIZED_CATEGORY = 'Miscellaneous';
export const UNCATEGORIZED_SUBCATEGORY = 'Helpers';
export const HIDDEN_NODES = ['n8n-nodes-base.start'];
export const ERROR_TRIGGER_NODE_NAME = 'n8n-nodes-base.errorTrigger';
export const WEBHOOK_NODE_NAME = 'n8n-nodes-base.webhook';
export const HTTP_REQUEST_NODE_NAME = 'n8n-nodes-base.httpRequest';
export const PERSONALIZED_CATEGORY = 'Suggested Nodes ✨';
export const HIDDEN_NODES = [START_NODE_TYPE];
export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3';
// General
export const INSTANCE_ID_HEADER = 'n8n-instance-id';
export const WAIT_TIME_UNLIMITED = '3000-01-01T00:00:00.000Z';
export const WORK_AREA_KEY = 'workArea';
export const AUTOMATION_CONSULTING_WORK_AREA = 'automationConsulting';
export const FINANCE_WORK_AREA = 'finance';
export const HR_WORK_AREA = 'HR';
export const IT_ENGINEERING_WORK_AREA = 'IT-Engineering';
export const LEGAL_WORK_AREA = 'legal';
export const MARKETING_WORK_AREA = 'marketing-growth';
export const PRODUCT_WORK_AREA = 'product';
export const SALES_BUSINESSDEV_WORK_AREA = 'sales-businessDevelopment';
export const SECURITY_WORK_AREA = 'security';
export const SUPPORT_WORK_AREA = 'support';
export const OPS_WORK_AREA = 'ops';
export const OTHER_WORK_AREA_OPTION = 'other';
export const COMPANY_SIZE_KEY = 'companySize';
export const COMPANY_SIZE_20_OR_LESS = '<20';
export const COMPANY_SIZE_20_99 = '20-99';
export const COMPANY_SIZE_100_499 = '100-499';
export const COMPANY_SIZE_500_999 = '500-999';
export const COMPANY_SIZE_1000_OR_MORE = '1000+';
export const COMPANY_SIZE_PERSONAL_USE = 'personalUser';
export const CODING_SKILL_KEY = 'codingSkill';
export const OTHER_WORK_AREA_KEY = 'otherWorkArea';

View File

@@ -9,7 +9,6 @@ import 'vue-prism-editor/dist/VuePrismEditor.css';
import 'vue-json-pretty/lib/styles.css';
import './n8n-theme.scss';
import "@fontsource/open-sans/latin-300.css";
import "@fontsource/open-sans/latin-400.css";
import "@fontsource/open-sans/latin-600.css";
import "@fontsource/open-sans/latin-700.css";
@@ -18,6 +17,7 @@ import App from '@/App.vue';
import router from './router';
import { runExternalHook } from './components/mixins/externalHooks';
import { TelemetryPlugin } from './plugins/telemetry';
import { store } from './store';
@@ -26,6 +26,8 @@ router.afterEach((to, from) => {
runExternalHook('main.routeChange', store, { from, to });
});
Vue.use(TelemetryPlugin);
new Vue({
router,
store,

View File

@@ -0,0 +1,82 @@
import { AUTOMATION_CONSULTING_WORK_AREA, CALENDLY_TRIGGER_NODE_TYPE, CLEARBIT_NODE_TYPE, COMPANY_SIZE_1000_OR_MORE, COMPANY_SIZE_500_999, CRON_NODE_TYPE, ELASTIC_SECURITY_NODE_TYPE, EMAIL_SEND_NODE_TYPE, EXECUTE_COMMAND_NODE_TYPE, FINANCE_WORK_AREA, FUNCTION_NODE_TYPE, GITHUB_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE, IF_NODE_TYPE, ITEM_LISTS_NODE_TYPE, IT_ENGINEERING_WORK_AREA, JIRA_TRIGGER_NODE_TYPE, MICROSOFT_EXCEL_NODE_TYPE, MICROSOFT_TEAMS_NODE_TYPE, PERSONALIZATION_MODAL_KEY, PAGERDUTY_NODE_TYPE, PRODUCT_WORK_AREA, QUICKBOOKS_NODE_TYPE, SALESFORCE_NODE_TYPE, SALES_BUSINESSDEV_WORK_AREA, SECURITY_WORK_AREA, SEGMENT_NODE_TYPE, SET_NODE_TYPE, SLACK_NODE_TYPE, SPREADSHEET_FILE_NODE_TYPE, SWITCH_NODE_TYPE, WEBHOOK_NODE_TYPE, XERO_NODE_TYPE, COMPANY_SIZE_KEY, WORK_AREA_KEY, CODING_SKILL_KEY } from '@/constants';
import { IPersonalizationSurveyAnswers } from '@/Interface';
export function getPersonalizedNodeTypes(answers: IPersonalizationSurveyAnswers) {
const companySize = answers[COMPANY_SIZE_KEY];
const workArea = answers[WORK_AREA_KEY];
if (companySize === null && workArea === null && answers[CODING_SKILL_KEY] === null) {
return [];
}
let codingSkill = null;
if (answers[CODING_SKILL_KEY]) {
codingSkill = parseInt(answers[CODING_SKILL_KEY] as string, 10);
codingSkill = isNaN(codingSkill)? 0 : codingSkill;
}
let nodeTypes = [] as string[];
if (workArea === IT_ENGINEERING_WORK_AREA || workArea === AUTOMATION_CONSULTING_WORK_AREA) {
nodeTypes = nodeTypes.concat(WEBHOOK_NODE_TYPE);
}
else {
nodeTypes = nodeTypes.concat(CRON_NODE_TYPE);
}
if (codingSkill !== null && codingSkill >= 4) {
nodeTypes = nodeTypes.concat(FUNCTION_NODE_TYPE);
}
else {
nodeTypes = nodeTypes.concat(ITEM_LISTS_NODE_TYPE);
}
if (codingSkill !== null && codingSkill < 3) {
nodeTypes = nodeTypes.concat(IF_NODE_TYPE);
}
else {
nodeTypes = nodeTypes.concat(SWITCH_NODE_TYPE);
}
if (companySize === COMPANY_SIZE_500_999 || companySize === COMPANY_SIZE_1000_OR_MORE) {
if (workArea === SALES_BUSINESSDEV_WORK_AREA) {
nodeTypes = nodeTypes.concat(SALESFORCE_NODE_TYPE);
}
else if (workArea === SECURITY_WORK_AREA) {
nodeTypes = nodeTypes.concat([ELASTIC_SECURITY_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
}
else if (workArea === PRODUCT_WORK_AREA) {
nodeTypes = nodeTypes.concat([JIRA_TRIGGER_NODE_TYPE, SEGMENT_NODE_TYPE]);
}
else if (workArea === IT_ENGINEERING_WORK_AREA) {
nodeTypes = nodeTypes.concat([GITHUB_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
}
else {
nodeTypes = nodeTypes.concat([MICROSOFT_EXCEL_NODE_TYPE, MICROSOFT_TEAMS_NODE_TYPE]);
}
}
else {
if (workArea === SALES_BUSINESSDEV_WORK_AREA) {
nodeTypes = nodeTypes.concat(CLEARBIT_NODE_TYPE);
}
else if (workArea === SECURITY_WORK_AREA) {
nodeTypes = nodeTypes.concat([PAGERDUTY_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
}
else if (workArea === PRODUCT_WORK_AREA) {
nodeTypes = nodeTypes.concat([JIRA_TRIGGER_NODE_TYPE, CALENDLY_TRIGGER_NODE_TYPE]);
}
else if (workArea === IT_ENGINEERING_WORK_AREA) {
nodeTypes = nodeTypes.concat([EXECUTE_COMMAND_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
}
else if (workArea === FINANCE_WORK_AREA) {
nodeTypes = nodeTypes.concat([XERO_NODE_TYPE, QUICKBOOKS_NODE_TYPE, SPREADSHEET_FILE_NODE_TYPE]);
}
else {
nodeTypes = nodeTypes.concat([EMAIL_SEND_NODE_TYPE, SLACK_NODE_TYPE]);
}
}
nodeTypes = nodeTypes.concat(SET_NODE_TYPE);
return nodeTypes;
}

View File

@@ -0,0 +1,75 @@
import { ActionContext, Module } from 'vuex';
import {
IN8nUISettings,
IPersonalizationSurveyAnswers,
IRootState,
ISettingsState,
} from '../Interface';
import { getSettings, submitPersonalizationSurvey } from '../api/settings';
import Vue from 'vue';
import { getPersonalizedNodeTypes } from './helper';
import { PERSONALIZATION_MODAL_KEY } from '@/constants';
const module: Module<ISettingsState, IRootState> = {
namespaced: true,
state: {
settings: {} as IN8nUISettings,
},
getters: {
personalizedNodeTypes(state: ISettingsState): string[] {
const answers = state.settings.personalizationSurvey && state.settings.personalizationSurvey.answers;
if (!answers) {
return [];
}
return getPersonalizedNodeTypes(answers);
},
},
mutations: {
setSettings(state: ISettingsState, settings: IN8nUISettings) {
state.settings = settings;
},
setPersonalizationAnswers(state: ISettingsState, answers: IPersonalizationSurveyAnswers) {
Vue.set(state.settings, 'personalizationSurvey', {
answers,
shouldShow: false,
});
},
},
actions: {
async getSettings(context: ActionContext<ISettingsState, IRootState>) {
const settings = await getSettings(context.rootGetters.getRestApiContext);
context.commit('setSettings', settings);
// todo refactor to this store
context.commit('setUrlBaseWebhook', settings.urlBaseWebhook, {root: true});
context.commit('setEndpointWebhook', settings.endpointWebhook, {root: true});
context.commit('setEndpointWebhookTest', settings.endpointWebhookTest, {root: true});
context.commit('setSaveDataErrorExecution', settings.saveDataErrorExecution, {root: true});
context.commit('setSaveDataSuccessExecution', settings.saveDataSuccessExecution, {root: true});
context.commit('setSaveManualExecutions', settings.saveManualExecutions, {root: true});
context.commit('setTimezone', settings.timezone, {root: true});
context.commit('setExecutionTimeout', settings.executionTimeout, {root: true});
context.commit('setMaxExecutionTimeout', settings.maxExecutionTimeout, {root: true});
context.commit('setVersionCli', settings.versionCli, {root: true});
context.commit('setInstanceId', settings.instanceId, {root: true});
context.commit('setOauthCallbackUrls', settings.oauthCallbackUrls, {root: true});
context.commit('setN8nMetadata', settings.n8nMetadata || {}, {root: true});
context.commit('versions/setVersionNotificationSettings', settings.versionNotifications, {root: true});
context.commit('setTelemetry', settings.telemetry, {root: true});
const showPersonalizationsModal = settings.personalizationSurvey && settings.personalizationSurvey.shouldShow && !settings.personalizationSurvey.answers;
if (showPersonalizationsModal) {
context.commit('ui/openModal', PERSONALIZATION_MODAL_KEY, {root: true});
}
return settings;
},
async submitPersonalizationSurvey(context: ActionContext<ISettingsState, IRootState>, results: IPersonalizationSurveyAnswers) {
await submitPersonalizationSurvey(context.rootGetters.getRestApiContext, results);
context.commit('setPersonalizationAnswers', results);
},
},
};
export default module;

View File

@@ -1,4 +1,4 @@
import { CREDENTIAL_EDIT_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import { CREDENTIAL_EDIT_MODAL_KEY, DUPLICATE_MODAL_KEY, PERSONALIZATION_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY } from '@/constants';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
import {
@@ -15,13 +15,22 @@ const module: Module<IUiState, IRootState> = {
mode: '',
activeId: null,
},
[CREDENTIAL_LIST_MODAL_KEY]: {
open: false,
},
[CREDENTIAL_SELECT_MODAL_KEY]: {
open: false,
},
[DUPLICATE_MODAL_KEY]: {
open: false,
},
[PERSONALIZATION_MODAL_KEY]: {
open: false,
},
[TAGS_MANAGER_MODAL_KEY]: {
open: false,
},
[WORKLOW_OPEN_MODAL_KEY]: {
[WORKFLOW_OPEN_MODAL_KEY]: {
open: false,
},
[VERSIONS_MODAL_KEY]: {
@@ -30,9 +39,6 @@ const module: Module<IUiState, IRootState> = {
[WORKFLOW_SETTINGS_MODAL_KEY]: {
open: false,
},
[CREDENTIAL_SELECT_MODAL_KEY]: {
open: false,
},
},
modalStack: [],
sidebarMenuCollapsed: true,
@@ -86,20 +92,8 @@ const module: Module<IUiState, IRootState> = {
},
},
actions: {
openTagsManagerModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', TAGS_MANAGER_MODAL_KEY);
},
openWorklfowOpenModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', WORKLOW_OPEN_MODAL_KEY);
},
openDuplicateModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', DUPLICATE_MODAL_KEY);
},
openUpdatesPanel: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', VERSIONS_MODAL_KEY);
},
openWorkflowSettingsModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', WORKFLOW_SETTINGS_MODAL_KEY);
openModal: async (context: ActionContext<IUiState, IRootState>, modalKey: string) => {
context.commit('openModal', modalKey);
},
openExisitngCredential: async (context: ActionContext<IUiState, IRootState>, { id }: {id: string}) => {
context.commit('setActiveId', {name: CREDENTIAL_EDIT_MODAL_KEY, id});
@@ -111,9 +105,6 @@ const module: Module<IUiState, IRootState> = {
context.commit('setMode', {name: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new'});
context.commit('openModal', CREDENTIAL_EDIT_MODAL_KEY);
},
openCredentialsSelectModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', CREDENTIAL_SELECT_MODAL_KEY);
},
},
};

View File

@@ -55,9 +55,6 @@ $--gift-notification-active-color: $--color-primary;
$--gift-notification-inner-color: $--color-primary;
$--gift-notification-outer-color: #fff;
// tags manager
$--tags-manager-min-height: 300px;
// based on element.io breakpoints
$--breakpoint-2xs: 600px;
$--breakpoint-xs: 768px;

View File

@@ -16,10 +16,6 @@ body {
color: $--custom-font-light;
font-weight: 400;
}
.text-very-light {
color: $--custom-font-very-light;
font-weight: 400;
}
.el-dialog {
border: var(--border-base);

View File

@@ -51,10 +51,13 @@ import {
N8nInput,
N8nInputLabel,
N8nInputNumber,
N8nHeading,
N8nMenu,
N8nMenuItem,
N8nSelect,
N8nSpinner,
N8nText,
N8nTooltip,
N8nOption,
} from 'n8n-design-system';
import { ElMessageBoxOptions } from "element-ui/types/message-box";
@@ -68,10 +71,13 @@ Vue.use(N8nInfoTip);
Vue.use(N8nInput);
Vue.use(N8nInputLabel);
Vue.use(N8nInputNumber);
Vue.use(N8nHeading);
Vue.use(N8nMenu);
Vue.use(N8nMenuItem);
Vue.use(N8nSelect);
Vue.use(N8nSpinner);
Vue.component('n8n-text', N8nText);
Vue.use(N8nTooltip);
Vue.use(N8nOption);
// element io

View File

@@ -0,0 +1,171 @@
import _Vue from "vue";
import {
ITelemetrySettings,
IDataObject,
} from 'n8n-workflow';
import { INodeCreateElement } from "@/Interface";
declare module 'vue/types/vue' {
interface Vue {
$telemetry: Telemetry;
}
}
export function TelemetryPlugin(vue: typeof _Vue): void {
const telemetry = new Telemetry();
Object.defineProperty(vue, '$telemetry', {
get() { return telemetry; },
});
Object.defineProperty(vue.prototype, '$telemetry', {
get() { return telemetry; },
});
}
interface IUserNodesPanelSessionData {
nodeFilter: string;
resultsNodes: string[];
filterMode: string;
}
interface IUserNodesPanelSession {
sessionId: string;
data: IUserNodesPanelSessionData;
}
class Telemetry {
private get telemetry() {
// @ts-ignore
return window.rudderanalytics;
}
private userNodesPanelSession: IUserNodesPanelSession = {
sessionId: '',
data: {
nodeFilter: '',
resultsNodes: [],
filterMode: 'Regular',
},
};
init(options: ITelemetrySettings, instanceId: string) {
if (options.enabled && !this.telemetry) {
if(!options.config) {
return;
}
this.loadTelemetryLibrary(options.config.key, options.config.url, { integrations: { All: false }, loadIntegration: false });
this.telemetry.identify(instanceId);
}
}
track(event: string, properties?: IDataObject) {
if (this.telemetry) {
this.telemetry.track(event, properties);
}
}
trackNodesPanel(event: string, properties: IDataObject = {}) {
if (this.telemetry) {
properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
switch (event) {
case 'nodeView.createNodeActiveChanged':
if (properties.createNodeActive !== false) {
this.resetNodesPanelSession();
properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
this.telemetry.track('User opened nodes panel', properties);
}
break;
case 'nodeCreateList.selectedTypeChanged':
this.userNodesPanelSession.data.filterMode = properties.new_filter as string;
this.telemetry.track('User changed nodes panel filter', properties);
break;
case 'nodeCreateList.destroyed':
if(this.userNodesPanelSession.data.nodeFilter.length > 0 && this.userNodesPanelSession.data.nodeFilter !== '') {
this.telemetry.track('User entered nodes panel search term', this.generateNodesPanelEvent());
}
break;
case 'nodeCreateList.nodeFilterChanged':
if((properties.newValue as string).length === 0 && this.userNodesPanelSession.data.nodeFilter.length > 0) {
this.telemetry.track('User entered nodes panel search term', this.generateNodesPanelEvent());
}
if((properties.newValue as string).length > (properties.oldValue as string || '').length) {
this.userNodesPanelSession.data.nodeFilter = properties.newValue as string;
this.userNodesPanelSession.data.resultsNodes = ((properties.filteredNodes || []) as INodeCreateElement[]).map((node: INodeCreateElement) => node.key);
}
break;
case 'nodeCreateList.onCategoryExpanded':
properties.is_subcategory = false;
this.telemetry.track('User viewed node category', properties);
break;
case 'nodeCreateList.onSubcategorySelected':
const selectedProperties = (properties.selected as IDataObject).properties as IDataObject;
if(selectedProperties && selectedProperties.subcategory) {
properties.category_name = selectedProperties.subcategory;
}
properties.is_subcategory = true;
delete properties.selected;
this.telemetry.track('User viewed node category', properties);
break;
case 'nodeView.addNodeButton':
this.telemetry.track('User added node to workflow canvas', properties);
break;
default:
break;
}
}
}
private resetNodesPanelSession() {
this.userNodesPanelSession.sessionId = `nodes_panel_session_${(new Date()).valueOf()}`;
this.userNodesPanelSession.data = {
nodeFilter: '',
resultsNodes: [],
filterMode: 'All',
};
}
private generateNodesPanelEvent() {
return {
search_string: this.userNodesPanelSession.data.nodeFilter,
results_count: this.userNodesPanelSession.data.resultsNodes.length,
filter_mode: this.userNodesPanelSession.data.filterMode,
nodes_panel_session_id: this.userNodesPanelSession.sessionId,
};
}
private loadTelemetryLibrary(key: string, url: string, options: IDataObject) {
// @ts-ignore
window.rudderanalytics = window.rudderanalytics || [];
this.telemetry.methods = ["load", "page", "track", "identify", "alias", "group", "ready", "reset", "getAnonymousId", "setAnonymousId"];
this.telemetry.factory = (t: any) => { // tslint:disable-line:no-any
return (...args: any[]) => { // tslint:disable-line:no-any
const r = Array.prototype.slice.call(args);
r.unshift(t);
this.telemetry.push(r);
return this.telemetry;
};
};
for (let t = 0; t < this.telemetry.methods.length; t++) {
const r = this.telemetry.methods[t];
this.telemetry[r] = this.telemetry.factory(r);
}
this.telemetry.loadJS = () => {
const r = document.createElement("script");
r.type = "text/javascript";
r.async = !0;
r.src = "https://cdn.rudderlabs.com/v1/rudder-analytics.min.js";
const a = document.getElementsByTagName("script")[0];
if(a && a.parentNode) {
a.parentNode.insertBefore(r, a);
}
};
this.telemetry.loadJS();
this.telemetry.load(key, url, options);
}
}

View File

@@ -0,0 +1 @@
declare module 'rudder-sdk-js';

View File

@@ -7,18 +7,17 @@ import { PLACEHOLDER_EMPTY_WORKFLOW_ID, DEFAULT_NODETYPE_VERSION } from '@/const
import {
IConnection,
IConnections,
ICredentialType,
IDataObject,
INodeConnections,
INodeIssueData,
INodeTypeDescription,
IRunData,
ITelemetrySettings,
ITaskData,
IWorkflowSettings,
} from 'n8n-workflow';
import {
ICredentialsResponse,
IExecutionResponse,
IExecutionsCurrentSummaryExtended,
IRootState,
@@ -35,6 +34,7 @@ import {
import credentials from './modules/credentials';
import tags from './modules/tags';
import settings from './modules/settings';
import ui from './modules/ui';
import workflows from './modules/workflows';
import versions from './modules/versions';
@@ -87,11 +87,13 @@ const state: IRootState = {
},
sidebarMenuItems: [],
instanceId: '',
telemetry: null,
};
const modules = {
credentials,
tags,
settings,
workflows,
versions,
ui,
@@ -541,6 +543,9 @@ export const store = new Vuex.Store({
setInstanceId(state, instanceId: string) {
Vue.set(state, 'instanceId', instanceId);
},
setTelemetry(state, telemetry: ITelemetrySettings) {
Vue.set(state, 'telemetry', telemetry);
},
setOauthCallbackUrls(state, urls: IDataObject) {
Vue.set(state, 'oauthCallbackUrls', urls);
},
@@ -678,6 +683,10 @@ export const store = new Vuex.Store({
return state.stateIsDirty;
},
instanceId: (state): string => {
return state.instanceId;
},
saveDataErrorExecution: (state): string => {
return state.saveDataErrorExecution;
},
@@ -699,6 +708,9 @@ export const store = new Vuex.Store({
versionCli: (state): string => {
return state.versionCli;
},
telemetry: (state): ITelemetrySettings | null => {
return state.telemetry;
},
oauthCallbackUrls: (state): object => {
return state.oauthCallbackUrls;
},

View File

@@ -109,7 +109,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, START_NODE_TYPE } from '@/constants';
import { NODE_NAME_PREFIX, PLACEHOLDER_EMPTY_WORKFLOW_ID, START_NODE_TYPE, WEBHOOK_NODE_TYPE, WORKFLOW_OPEN_MODAL_KEY } from '@/constants';
import { copyPaste } from '@/components/mixins/copyPaste';
import { externalHooks } from '@/components/mixins/externalHooks';
import { genericHelpers } from '@/components/mixins/genericHelpers';
@@ -175,7 +175,7 @@ const SIDEBAR_WIDTH = 65;
const DEFAULT_START_NODE = {
name: 'Start',
type: 'n8n-nodes-base.start',
type: START_NODE_TYPE,
typeVersion: 1,
position: [
DEFAULT_START_POSITION_X,
@@ -345,7 +345,8 @@ export default mixins(
},
openNodeCreator () {
this.createNodeActive = true;
this.$externalHooks().run('nodeView.createNodeActiveChanged', { source: 'add_node_button' });
this.$externalHooks().run('nodeView.createNodeActiveChanged', { source: 'add_node_button', createNodeActive: this.createNodeActive });
this.$telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', { source: 'add_node_button', workflow_id: this.$store.getters.workflowId, createNodeActive: this.createNodeActive });
},
async openExecution (executionId: string) {
this.resetWorkspace();
@@ -374,6 +375,7 @@ export default mixins(
});
this.$externalHooks().run('execution.open', { workflowId: data.workflowData.id, workflowName: data.workflowData.name, executionId });
this.$telemetry.track('User opened read-only execution', { workflow_id: data.workflowData.id, execution_mode: data.mode, execution_finished: data.finished });
if (data.finished !== true && data.data.resultData.error) {
// Check if any node contains an error
@@ -392,12 +394,14 @@ export default mixins(
}
if (nodeErrorFound === false) {
const errorMessage = this.$getExecutionError(data.data.resultData.error);
const resultError = data.data.resultData.error;
const errorMessage = this.$getExecutionError(resultError);
const shouldTrack = resultError && resultError.node && resultError.node.type.startsWith('n8n-nodes-base');
this.$showMessage({
title: 'Failed execution',
message: errorMessage,
type: 'error',
});
}, shouldTrack);
if (data.data.resultData.error.stack) {
// Display some more information for now in console to make debugging easier
@@ -593,6 +597,9 @@ export default mixins(
} else if (e.key === 'Tab') {
this.createNodeActive = !this.createNodeActive && !this.isReadOnly;
this.$externalHooks().run('nodeView.createNodeActiveChanged', { source: 'tab', createNodeActive: this.createNodeActive });
this.$telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', { source: 'tab', workflow_id: this.$store.getters.workflowId, createNodeActive: this.createNodeActive });
} else if (e.key === this.controlKeyCode) {
this.ctrlKeyPressed = true;
} else if (e.key === 'F2' && !this.isReadOnly) {
@@ -627,7 +634,7 @@ export default mixins(
e.stopPropagation();
e.preventDefault();
this.$store.dispatch('ui/openWorklfowOpenModal');
this.$store.dispatch('ui/openModal', WORKFLOW_OPEN_MODAL_KEY);
} else if (e.key === 'n' && this.isCtrlKeyPressed(e) === true && e.altKey === true) {
// Create a new workflow
e.stopPropagation();
@@ -653,7 +660,7 @@ export default mixins(
return;
}
this.callDebounced('saveCurrentWorkflow', 1000);
this.callDebounced('saveCurrentWorkflow', 1000, undefined, true);
} else if (e.key === 'Enter') {
// Activate the last selected node
const lastSelectedNode = this.$store.getters.lastSelectedNode;
@@ -838,6 +845,12 @@ export default mixins(
this.getSelectedNodesToSave().then((data) => {
const nodeData = JSON.stringify(data, null, 2);
this.copyToClipboard(nodeData);
if (data.nodes.length > 0) {
this.$telemetry.track('User copied nodes', {
node_types: data.nodes.map((node) => node.type),
workflow_id: this.$store.getters.workflowId,
});
}
});
},
@@ -1011,6 +1024,10 @@ export default mixins(
}
}
this.$telemetry.track('User pasted nodes', {
workflow_id: this.$store.getters.workflowId,
});
return this.importWorkflowData(workflowData!);
},
@@ -1029,6 +1046,8 @@ export default mixins(
}
this.stopLoading();
this.$telemetry.track('User imported workflow', { source: 'url', workflow_id: this.$store.getters.workflowId });
return workflowData;
},
@@ -1247,6 +1266,7 @@ export default mixins(
this.$store.commit('setStateDirty', true);
this.$externalHooks().run('nodeView.addNodeButton', { nodeTypeName });
this.$telemetry.trackNodesPanel('nodeView.addNodeButton', { node_type: nodeTypeName, workflow_id: this.$store.getters.workflowId });
// Automatically deselect all nodes and select the current one and also active
// current node
@@ -1370,7 +1390,8 @@ export default mixins(
// Display the node-creator
this.createNodeActive = true;
this.$externalHooks().run('nodeView.createNodeActiveChanged', { source: 'node_connection_drop' });
this.$externalHooks().run('nodeView.createNodeActiveChanged', { source: 'node_connection_drop', createNodeActive: this.createNodeActive });
this.$telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', { source: 'node_connection_drop', workflow_id: this.$store.getters.workflowId, createNodeActive: this.createNodeActive });
});
this.instance.bind('connection', (info: OnConnectionBindInfo) => {
@@ -1752,6 +1773,8 @@ export default mixins(
setTimeout(() => {
this.nodeSelectedByName(newNodeData.name, true);
});
this.$telemetry.track('User duplicated node', { node_type: node.type, workflow_id: this.$store.getters.workflowId });
},
removeNode (nodeName: string) {
if (this.editAllowedCheck() === false) {
@@ -1761,7 +1784,7 @@ export default mixins(
const node = this.$store.getters.nodeByName(nodeName);
// "requiredNodeTypes" are also defined in cli/commands/run.ts
const requiredNodeTypes = [ 'n8n-nodes-base.start' ];
const requiredNodeTypes = [ START_NODE_TYPE ];
if (requiredNodeTypes.includes(node.type)) {
// The node is of the required type so check first
@@ -1961,7 +1984,7 @@ export default mixins(
node.parameters = nodeParameters !== null ? nodeParameters : {};
// if it's a webhook and the path is empty set the UUID as the default path
if (node.type === 'n8n-nodes-base.webhook' && node.parameters.path === '') {
if (node.type === WEBHOOK_NODE_TYPE && node.parameters.path === '') {
node.parameters.path = node.webhookId as string;
}
}
@@ -2240,21 +2263,7 @@ export default mixins(
this.$store.commit('setActiveWorkflows', activeWorkflows);
},
async loadSettings (): Promise<void> {
const settings = await this.restApi().getSettings() as IN8nUISettings;
this.$store.commit('setUrlBaseWebhook', settings.urlBaseWebhook);
this.$store.commit('setEndpointWebhook', settings.endpointWebhook);
this.$store.commit('setEndpointWebhookTest', settings.endpointWebhookTest);
this.$store.commit('setSaveDataErrorExecution', settings.saveDataErrorExecution);
this.$store.commit('setSaveDataSuccessExecution', settings.saveDataSuccessExecution);
this.$store.commit('setSaveManualExecutions', settings.saveManualExecutions);
this.$store.commit('setTimezone', settings.timezone);
this.$store.commit('setExecutionTimeout', settings.executionTimeout);
this.$store.commit('setMaxExecutionTimeout', settings.maxExecutionTimeout);
this.$store.commit('setVersionCli', settings.versionCli);
this.$store.commit('setInstanceId', settings.instanceId);
this.$store.commit('setOauthCallbackUrls', settings.oauthCallbackUrls);
this.$store.commit('setN8nMetadata', settings.n8nMetadata || {});
this.$store.commit('versions/setVersionNotificationSettings', settings.versionNotifications);
await this.$store.dispatch('settings/getSettings');
},
async loadNodeTypes (): Promise<void> {
const nodeTypes = await this.restApi().getNodeTypes();