Add Value Survey (#2499)

* N8N-2619 Value Survey Front-end

* N8N-2619 Added Contact Prompt Modal and logic

* N8N-2619 Added Link to Toast Message on Successful submitting ValueSurvey

* N8N-2619 Updated TypeForm URL in ValueSurvey Success Toast

* N8N-2619 Fixed Typo placeholder for ValueSurvey and ContactPrompt Modal

* N8N-2619 Fixed Toast not close automatically in ValueSurvey, Make part of the title bold, Changed Font-sizes on Value Survey

* N8N-2619 Fixed Close Button on ValueSurvey, Vertical Allignment for Questions in ValueSurvey Drawer

* N8N-2619 Make Value Survey with static height

* N8N-2619 Fixed Telemetry Events on closing ValueSurvey

* N8N-2619 Updated N8NPrompt Interface, Added Dynamic Title and Description on ContactPrompt Modal

* N8N-2619 Reversed Answers in ValueSurveyModal

* N8N-2619 Added Telemetry Event on user close ValueSurvey on second Question

* N8N-2619 Re-work, Optimized, Simplify the code after technical review

* N8N-2619 Fixed If else statement in openUserPromptsIfPossible

* N8N-2619 Change Text under Email Box - ValueSurvey, ContactPrompt, Added new Telemetary Event on ValueSurvey Open, Fixed Toast to close aftet 15s

* N8N-2619 Change ContactPrompt Modal to use Atoms like N8N-Heading and N8N-Text

* N8N-2619 Change Design & Logic on ValueSurvey - When to open

* N8N-2619 Updated Value Survey with new Telemetry Events (Refactor), Simplified functions, Added Atoms in ValueSurvey + ContactPrompt

* N8N-2619 Refactor in Interfaces, Updated/Refactor Getters and Vuex store props

* N8N-2619 Defined IN8nValueSurveyData interface

* N8N-2619 Disabled Keyboard shortcuts to be activated on typing in ValueSurvey Input field, Fire an event on Saving WF from Menu and with shorcut, Make Drawer keep-alive

* N8N-2619 Added Atoms in Value Survey Modal (buttons), Rework css

* N8N-2619 Added Responses on ValueSurvey Submit

* N8N-2619 Added Response for SubmittingContactInfo

* N8N-2619 Added loading state for buttons / ValueSurvey

* N8N-2619 Changed ValueSurvey and ContactPrompt to support enter key on submit, Simplifed closeDialog Function, Changed css for button in ValueSurvey, Prevent showing the Modals if Saving WF fails, Add Debouncing on showing prompt

* N8N-2619 Added IsTelemetryEnabled Getter in Vuex.store

* N8N-2619 Created/Added N8N-SquareButton to Design-system

* N8N-2619 Change Promise in MainSideBar with Async/Await function, Nitpick simpliefied

* N8N-2619 Update the text under the input fields

* N8N-2619 Update the text in ContactPrompt Modal

* N8N-2619 Allign Send button on ValueSurvey Modal

* N8N-2619 Fixed Input in ValueSurvey Modal

* N8N-2619 Check if the workflow is saving

* N8N-2619 Check if WF is saving to allowed performs fetchPromptsData

* N8N-2619 Hotfix

* N8N-2619 Fixed ValueSurvey, Updated onSaveButtonClick function, Created onSaveKeyboardShortcut function in NodeView

* N8N-2619 Rework css module classes for ValueSurvey, Simplified

* N8N-2619 Simplified N8N-SquareButton Component, removed dead code

* N8N-2619 Added Breakpoints for Mobile/Tablet View

* N8N-2619 Formatting fix

* N8N-2619 Update css for mobile/tablet, change promises to asyn/await functions in ContactPrompt and ValueSurvey, Added isActive prop to ValueSurvey

* N8N-2619 Update TEMPLATE_BASE_URL to production
This commit is contained in:
Oliver Trajceski 2021-12-11 17:38:16 +01:00 committed by GitHub
parent 66745033e6
commit 42742de876
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 634 additions and 16 deletions

View file

@ -13,7 +13,7 @@ export default {
color: {
control: {
type: 'select',
options: ['primary', 'text-dark', 'text-base', 'text-light'],
options: ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'],
},
},
},

View file

@ -23,7 +23,7 @@ export default {
},
color: {
type: String,
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light'].includes(value),
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'].includes(value),
},
},
methods: {

View file

@ -0,0 +1,27 @@
import N8nSquareButton from './SquareButton.vue';
import { action } from '@storybook/addon-actions';
export default {
title: 'Atoms/SquareButton',
component: N8nSquareButton,
argTypes: {
label: {
control: 'text',
},
},
};
const methods = {
onClick: action('click'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nSquareButton,
},
template: '<n8n-square-button v-bind="$props" @click="onClick"></n8n-square-button>',
methods,
});
export const SquareButton = Template.bind({});

View file

@ -0,0 +1,43 @@
<template functional>
<button :class="$style.button" @click="(e) => listeners.click && listeners.click(e)">
<span :class="$style.text" v-text="props.label" />
</button>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'n8n-square-button',
props: {
label: {
type: String,
},
},
});
</script>
<style lang="scss" module>
.button {
width: 28px;
height: 29px;
border-radius: var(--border-radius-base);
border: var(--color-background-xlight);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
.text {
color: var(--color-primary) !important;
}
}
}
.text {
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-loose);
color: var(--color-background-dark);
}
</style>

View file

@ -0,0 +1,3 @@
import N8nSquareButton from './SquareButton.vue';
export default N8nSquareButton;

View file

@ -13,7 +13,7 @@ export default {
color: {
control: {
type: 'select',
options: ['primary', 'text-dark', 'text-base', 'text-light'],
options: ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'],
},
},
},

View file

@ -20,7 +20,7 @@ export default Vue.extend({
},
color: {
type: String,
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light'].includes(value),
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'].includes(value),
},
align: {
type: String,

View file

@ -10,6 +10,7 @@ import N8nMenu from './N8nMenu';
import N8nMenuItem from './N8nMenuItem';
import N8nSelect from './N8nSelect';
import N8nSpinner from './N8nSpinner';
import N8nSquareButton from './N8nSquareButton';
import N8nText from './N8nText';
import N8nTooltip from './N8nTooltip';
import N8nOption from './N8nOption';
@ -27,6 +28,7 @@ export {
N8nMenuItem,
N8nSelect,
N8nSpinner,
N8nSquareButton,
N8nText,
N8nTooltip,
N8nOption,

View file

@ -486,6 +486,21 @@ export interface IPersonalizationSurvey {
shouldShow: boolean;
}
export interface IN8nPrompts {
message: string;
title: string;
showContactPrompt: boolean;
showValueSurvey: boolean;
}
export interface IN8nValueSurveyData {
[key: string]: string;
}
export interface IN8nPromptResponse {
updated: boolean;
}
export interface IN8nUISettings {
endpointWebhook: string;
endpointWebhookTest: string;
@ -690,6 +705,7 @@ export interface IUiState {
export interface ISettingsState {
settings: IN8nUISettings;
promptsData: IN8nPrompts;
}
export interface IVersionsState {

View file

@ -93,3 +93,7 @@ export async function makeRestApiRequest(context: IRestApiContext, method: Metho
export async function get(baseURL: string, endpoint: string, params?: IDataObject, headers?: IDataObject) {
return await request({method: 'GET', baseURL, endpoint, headers, data: params});
}
export async function post(baseURL: string, endpoint: string, params?: IDataObject, headers?: IDataObject) {
return await request({method: 'POST', baseURL, endpoint, headers, data: params});
}

View file

@ -1,6 +1,7 @@
import { IDataObject } from 'n8n-workflow';
import { IRestApiContext, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface';
import { makeRestApiRequest } from './helpers';
import { IRestApiContext, IN8nPrompts, IN8nValueSurveyData, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface';
import { makeRestApiRequest, get, post } from './helpers';
import { TEMPLATES_BASE_URL } from '@/constants';
export async function getSettings(context: IRestApiContext): Promise<IN8nUISettings> {
return await makeRestApiRequest(context, 'GET', '/settings');
@ -10,3 +11,15 @@ export async function submitPersonalizationSurvey(context: IRestApiContext, para
await makeRestApiRequest(context, 'POST', '/user-survey', params as unknown as IDataObject);
}
export async function getPromptsData(instanceId: string): Promise<IN8nPrompts> {
return await get(TEMPLATES_BASE_URL, '/prompts', {}, {'n8n-instance-id': instanceId});
}
export async function submitContactInfo(instanceId: string, email: string): Promise<void> {
return await post(TEMPLATES_BASE_URL, '/prompt', { email }, {'n8n-instance-id': instanceId});
}
export async function submitValueSurvey(instanceId: string, params: IN8nValueSurveyData): Promise<IN8nPrompts> {
return await post(TEMPLATES_BASE_URL, '/value-survey', params, {'n8n-instance-id': instanceId});
}

View file

@ -0,0 +1,128 @@
<template>
<Modal
:name="modalName"
:eventBus="modalBus"
:center="true"
:closeOnPressEscape="false"
:beforeClose="closeDialog"
customClass="contact-prompt-modal"
width="460px"
>
<template slot="header">
<n8n-heading tag="h2" size="xlarge" color="text-dark">{{ title }}</n8n-heading>
</template>
<template v-slot:content>
<div :class="$style.description">
<n8n-text size="medium" color="text-base">{{ description }}</n8n-text>
</div>
<div @keyup.enter="send">
<n8n-input v-model="email" placeholder="Your email address" />
</div>
<div :class="$style.disclaimer">
<n8n-text size="small" color="text-base"
>David from our product team will get in touch personally</n8n-text
>
</div>
</template>
<template v-slot:footer>
<div :class="$style.footer">
<n8n-button label="Send" float="right" @click="send" :disabled="!isEmailValid" />
</div>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import { IN8nPromptResponse } from '@/Interface';
import { VALID_EMAIL_REGEX } from '@/constants';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import Modal from './Modal.vue';
export default mixins(workflowHelpers).extend({
components: { Modal },
name: 'ContactPromptModal',
props: ['modalName'],
data() {
return {
email: '',
modalBus: new Vue(),
};
},
computed: {
...mapGetters({
promptsData: 'settings/getPromptsData',
}),
title(): string {
if (this.promptsData && this.promptsData.title) {
return this.promptsData.title;
}
return 'Youre a power user 💪';
},
description(): string {
if (this.promptsData && this.promptsData.message) {
return this.promptsData.message;
}
return 'Your experience with n8n can help us improve — for you and our entire community.';
},
isEmailValid(): boolean {
return VALID_EMAIL_REGEX.test(String(this.email).toLowerCase());
},
},
methods: {
closeDialog(): void {
this.$telemetry.track('User closed email modal', {
instance_id: this.$store.getters.instanceId,
email: null,
});
this.$store.commit('ui/closeTopModal');
},
async send() {
if (this.isEmailValid) {
const response: IN8nPromptResponse = await this.$store.dispatch(
'settings/submitContactInfo',
this.email,
);
if (response.updated) {
this.$telemetry.track('User closed email modal', {
instance_id: this.$store.getters.instanceId,
email: this.email,
});
this.$showMessage({
title: 'Thanks!',
message: "It's people like you that help make n8n better",
type: 'success',
});
}
this.$store.commit('ui/closeTopModal');
}
},
},
});
</script>
<style lang="scss" module>
.description {
margin-bottom: var(--spacing-s);
}
.disclaimer {
margin-top: var(--spacing-4xs);
}
</style>
<style lang="scss">
.dialog-wrapper {
.contact-prompt-modal {
.el-dialog__body {
padding: 16px 24px 24px;
}
}
}
</style>

View file

@ -140,8 +140,9 @@ export default mixins(workflowHelpers).extend({
},
},
methods: {
onSaveButtonClick () {
this.saveCurrentWorkflow(undefined);
async onSaveButtonClick () {
const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
},
onTagsEditEnable() {
this.$data.appliedTagIds = this.currentWorkflowTagIds;
@ -172,7 +173,7 @@ export default mixins(workflowHelpers).extend({
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

@ -425,7 +425,8 @@ export default mixins(
saveAs(blob, workflowName + '.json');
} else if (key === 'workflow-save') {
this.saveCurrentWorkflow(undefined);
const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
} else if (key === 'workflow-duplicate') {
this.$store.dispatch('ui/openModal', DUPLICATE_MODAL_KEY);
} else if (key === 'help-about') {

View file

@ -3,7 +3,9 @@
:direction="direction"
:visible="visible"
:size="width"
:before-close="close"
:before-close="beforeClose"
:modal="modal"
:wrapperClosable="wrapperClosable"
>
<template v-slot:title>
<slot name="header" />
@ -23,15 +25,26 @@ export default Vue.extend({
name: {
type: String,
},
beforeClose: {
type: Function,
},
eventBus: {
type: Vue,
},
direction: {
type: String,
},
modal: {
type: Boolean,
default: true,
},
width: {
type: String,
},
wrapperClosable: {
type: Boolean,
default: true,
},
},
mounted() {
window.addEventListener('keydown', this.onWindowKeydown);

View file

@ -1,5 +1,13 @@
<template>
<div>
<ModalRoot :name="CONTACT_PROMPT_MODAL_KEY">
<template v-slot:default="{ modalName }">
<ContactPromptModal
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="CREDENTIAL_EDIT_MODAL_KEY">
<template v-slot="{ modalName, activeId, mode }">
<CredentialEdit
@ -39,6 +47,12 @@
<UpdatesPanel />
</ModalRoot>
<ModalRoot :name="VALUE_SURVEY_MODAL_KEY" :keepAlive="true">
<template v-slot:default="{ active }">
<ValueSurvey :isActive="active"/>
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_OPEN_MODAL_KEY">
<WorkflowOpen />
</ModalRoot>
@ -51,8 +65,9 @@
<script lang="ts">
import Vue from "vue";
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 { CONTACT_PROMPT_MODAL_KEY, 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, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import ContactPromptModal from './ContactPromptModal.vue';
import CredentialEdit from "./CredentialEdit/CredentialEdit.vue";
import CredentialsList from "./CredentialsList.vue";
import CredentialsSelectModal from "./CredentialsSelectModal.vue";
@ -61,12 +76,14 @@ import ModalRoot from "./ModalRoot.vue";
import PersonalizationModal from "./PersonalizationModal.vue";
import TagsManager from "./TagsManager/TagsManager.vue";
import UpdatesPanel from "./UpdatesPanel.vue";
import ValueSurvey from "./ValueSurvey.vue";
import WorkflowSettings from "./WorkflowSettings.vue";
import WorkflowOpen from "./WorkflowOpen.vue";
export default Vue.extend({
name: "Modals",
components: {
ContactPromptModal,
CredentialEdit,
CredentialsList,
CredentialsSelectModal,
@ -75,10 +92,12 @@ export default Vue.extend({
PersonalizationModal,
TagsManager,
UpdatesPanel,
ValueSurvey,
WorkflowSettings,
WorkflowOpen,
},
data: () => ({
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_LIST_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
@ -88,6 +107,7 @@ export default Vue.extend({
VERSIONS_MODAL_KEY,
WORKFLOW_OPEN_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
VALUE_SURVEY_MODAL_KEY,
}),
});
</script>

View file

@ -0,0 +1,283 @@
<template>
<ModalDrawer
:name="VALUE_SURVEY_MODAL_KEY"
:beforeClose="closeDialog"
:modal="false"
:wrapperClosable="false"
direction="btt"
width="120px"
class="value-survey"
>
<template slot="header">
<div :class="$style.title">
<n8n-heading tag="h2" size="medium" color="text-xlight">{{ getTitle }}</n8n-heading>
</div>
</template>
<template slot="content">
<section :class="$style.content">
<div v-if="showButtons" :class="$style.wrapper">
<div :class="$style.buttons">
<div v-for="value in 11" :key="value - 1" :class="$style.container">
<n8n-square-button
:label="(value - 1).toString()"
@click="selectSurveyValue((value - 1).toString())"
/>
</div>
</div>
<div :class="$style.text">
<n8n-text size="small" color="text-xlight">Not likely</n8n-text>
<n8n-text size="small" color="text-xlight">Very likely</n8n-text>
</div>
</div>
<div v-else :class="$style.email">
<div :class="$style.input" @keyup.enter="send">
<n8n-input
v-model="form.email"
placeholder="Your email address"
size="medium"
@input="onInputChange"
/>
<div :class="$style.button">
<n8n-button label="Send" float="right" @click="send" :disabled="!isEmailValid" />
</div>
</div>
<div :class="$style.disclaimer">
<n8n-text size="small" color="text-xlight">
David from our product team will get in touch personally
</n8n-text>
</div>
</div>
</section>
</template>
</ModalDrawer>
</template>
<script lang="ts">
import { VALID_EMAIL_REGEX, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import { IN8nPromptResponse } from '@/Interface';
import ModalDrawer from './ModalDrawer.vue';
import mixins from 'vue-typed-mixins';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
const DEFAULT_TITLE = `How likely are you to recommend n8n to a friend or colleague?`;
const GREAT_FEEDBACK_TITLE = `Great to hear! Can we reach out to see how we can make n8n even better for you?`;
const DEFAULT_FEEDBACK_TITLE = `Thanks for your feedback! We'd love to understand how we can improve. Can we reach out?`;
export default mixins(workflowHelpers).extend({
name: 'ValueSurvey',
props: ['isActive'],
components: {
ModalDrawer,
},
watch: {
isActive(isActive) {
if (isActive) {
this.$telemetry.track('User shown value survey', {
instance_id: this.$store.getters.instanceId,
});
}
},
},
computed: {
getTitle(): string {
if (this.form.value !== '') {
if (Number(this.form.value) > 7) {
return GREAT_FEEDBACK_TITLE;
} else {
return DEFAULT_FEEDBACK_TITLE;
}
} else {
return DEFAULT_TITLE;
}
},
isEmailValid(): boolean {
return VALID_EMAIL_REGEX.test(String(this.form.email).toLowerCase());
},
},
data() {
return {
form: {
email: '',
value: '',
},
showButtons: true,
VALUE_SURVEY_MODAL_KEY,
};
},
methods: {
closeDialog(): void {
if (this.form.value === '') {
this.$telemetry.track('User responded value survey score', {
instance_id: this.$store.getters.instanceId,
nps: '',
});
} else {
this.$telemetry.track('User responded value survey email', {
instance_id: this.$store.getters.instanceId,
email: '',
});
}
this.$store.commit('ui/closeTopModal');
},
onInputChange(value: string) {
this.form.email = value;
},
async selectSurveyValue(value: string) {
this.form.value = value;
this.showButtons = false;
const response: IN8nPromptResponse = await this.$store.dispatch(
'settings/submitValueSurvey',
{ value: this.form.value },
);
if (response.updated) {
this.$telemetry.track('User responded value survey score', {
instance_id: this.$store.getters.instanceId,
nps: this.form.value,
});
}
},
async send() {
if (this.isEmailValid) {
const response: IN8nPromptResponse = await this.$store.dispatch(
'settings/submitValueSurvey',
{
email: this.form.email,
value: this.form.value,
},
);
if (response.updated) {
this.$telemetry.track('User responded value survey email', {
instance_id: this.$store.getters.instanceId,
email: this.form.email,
});
this.$showMessage({
title: 'Thanks for your feedback',
message: `If youd like to help even more, answer this <a target="_blank" href="https://n8n-community.typeform.com/quicksurvey#nps=${this.form.value}&instance_id=${this.$store.getters.instanceId}">quick survey.</a>`,
type: 'success',
duration: 15000,
});
}
setTimeout(() => {
this.form.value = '';
this.form.email = '';
this.showButtons = true;
}, 1000);
this.$store.commit('ui/closeTopModal');
}
},
},
});
</script>
<style module lang="scss">
.title {
height: 16px;
text-align: center;
@media (max-width: $--breakpoint-xs) {
margin-top: 10px;
padding: 0 15px;
}
}
.content {
display: flex;
justify-content: center;
@media (max-width: $--breakpoint-xs) {
margin-top: 20px;
}
}
.wrapper {
display: flex;
flex-direction: column;
}
.buttons {
display: flex;
}
.container {
margin: 0 8px;
@media (max-width: $--breakpoint-xs) {
margin: 0 4px;
}
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
.text {
margin-top: 8px;
display: flex;
justify-content: space-between;
}
.input {
display: flex;
align-items: center;
}
.button {
margin-left: 10px;
}
.disclaimer {
margin-top: var(--spacing-4xs);
}
</style>
<style lang="scss">
.value-survey {
height: 120px;
top: auto;
@media (max-width: $--breakpoint-xs) {
height: 140px;
}
.el-drawer {
background: var(--color-background-dark);
@media (max-width: $--breakpoint-xs) {
height: 140px !important;
}
&__header {
height: 50px;
margin: 0;
padding: 18px 0 16px;
.el-drawer__close-btn {
top: 12px;
right: 16px;
position: absolute;
@media (max-width: $--breakpoint-xs) {
top: 2px;
right: 2px;
}
}
.el-dialog__close {
font-weight: var(--font-weight-bold);
color: var(--color-text-xlight);
}
}
}
}
</style>

View file

@ -148,6 +148,7 @@ export default mixins(
this.$emit('workflowActiveChanged', { id: this.workflowId, active: newActiveState });
this.loading = false;
this.$store.dispatch('settings/fetchPromptsData');
},
async displayActivationError () {
let errorMessage: string;

View file

@ -26,6 +26,8 @@ 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';
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey';
// breakpoints
export const BREAKPOINT_SM = 768;
@ -132,3 +134,5 @@ export const CODING_SKILL_KEY = 'codingSkill';
export const OTHER_WORK_AREA_KEY = 'otherWorkArea';
export const OTHER_COMPANY_INDUSTRY_KEY = 'otherCompanyIndustry';
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,}))$/;

View file

@ -1,19 +1,22 @@
import { ActionContext, Module } from 'vuex';
import {
IN8nPrompts,
IN8nUISettings,
IN8nValueSurveyData,
IPersonalizationSurveyAnswers,
IRootState,
ISettingsState,
} from '../Interface';
import { getSettings, submitPersonalizationSurvey } from '../api/settings';
import { getPromptsData, getSettings, submitValueSurvey, submitPersonalizationSurvey, submitContactInfo } from '../api/settings';
import Vue from 'vue';
import { getPersonalizedNodeTypes } from './helper';
import { PERSONALIZATION_MODAL_KEY } from '@/constants';
import { CONTACT_PROMPT_MODAL_KEY, PERSONALIZATION_MODAL_KEY, VALUE_SURVEY_MODAL_KEY } from '@/constants';
const module: Module<ISettingsState, IRootState> = {
namespaced: true,
state: {
settings: {} as IN8nUISettings,
promptsData: {} as IN8nPrompts,
},
getters: {
personalizedNodeTypes(state: ISettingsState): string[] {
@ -24,6 +27,9 @@ const module: Module<ISettingsState, IRootState> = {
return getPersonalizedNodeTypes(answers);
},
getPromptsData(state: ISettingsState) {
return state.promptsData;
},
},
mutations: {
setSettings(state: ISettingsState, settings: IN8nUISettings) {
@ -35,6 +41,9 @@ const module: Module<ISettingsState, IRootState> = {
shouldShow: false,
});
},
setPromptsData(state: ISettingsState, promptsData: IN8nPrompts) {
Vue.set(state, 'promptsData', promptsData);
},
},
actions: {
async getSettings(context: ActionContext<ISettingsState, IRootState>) {
@ -70,6 +79,40 @@ const module: Module<ISettingsState, IRootState> = {
context.commit('setPersonalizationAnswers', results);
},
async fetchPromptsData(context: ActionContext<ISettingsState, IRootState>) {
if (!context.rootGetters.isTelemetryEnabled) {
return;
}
try {
const promptsData: IN8nPrompts = await getPromptsData(context.state.settings.instanceId);
if (promptsData && promptsData.showContactPrompt) {
context.commit('ui/openModal', CONTACT_PROMPT_MODAL_KEY, {root: true});
} else if (promptsData && promptsData.showValueSurvey) {
context.commit('ui/openModal', VALUE_SURVEY_MODAL_KEY, {root: true});
}
context.commit('setPromptsData', promptsData);
} catch (e) {
return e;
}
},
async submitContactInfo(context: ActionContext<ISettingsState, IRootState>, email: string) {
try {
return await submitContactInfo(context.state.settings.instanceId, email);
} catch (e) {
return e;
}
},
async submitValueSurvey(context: ActionContext<ISettingsState, IRootState>, params: IN8nValueSurveyData) {
try {
return await submitValueSurvey(context.state.settings.instanceId, params);
} catch (e) {
return e;
}
},
},
};

View file

@ -1,4 +1,4 @@
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 { CONTACT_PROMPT_MODAL_KEY, 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, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
import {
@ -10,6 +10,9 @@ const module: Module<IUiState, IRootState> = {
namespaced: true,
state: {
modals: {
[CONTACT_PROMPT_MODAL_KEY]: {
open: false,
},
[CREDENTIAL_EDIT_MODAL_KEY]: {
open: false,
mode: '',
@ -33,6 +36,9 @@ const module: Module<IUiState, IRootState> = {
[WORKFLOW_OPEN_MODAL_KEY]: {
open: false,
},
[VALUE_SURVEY_MODAL_KEY]: {
open: false,
},
[VERSIONS_MODAL_KEY]: {
open: false,
},

View file

@ -55,6 +55,7 @@ import {
N8nMenuItem,
N8nSelect,
N8nSpinner,
N8nSquareButton,
N8nText,
N8nTooltip,
N8nOption,
@ -75,6 +76,7 @@ Vue.use(N8nMenu);
Vue.use(N8nMenuItem);
Vue.use(N8nSelect);
Vue.use(N8nSpinner);
Vue.component('n8n-square-button', N8nSquareButton);
Vue.component('n8n-text', N8nText);
Vue.use(N8nTooltip);
Vue.use(N8nOption);

View file

@ -648,6 +648,10 @@ export const store = new Vuex.Store({
return state.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID;
},
isTelemetryEnabled: (state) => {
return state.telemetry && state.telemetry.enabled;
},
currentWorkflowHasWebhookNode: (state: IRootState): boolean => {
return !!state.workflow.nodes.find((node: INodeUi) => !!node.webhookId);
},

View file

@ -327,6 +327,10 @@ export default mixins(
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();
},
async onSaveKeyboardShortcut () {
const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
},
openNodeCreator (source: string) {
this.createNodeActive = true;
this.$externalHooks().run('nodeView.createNodeActiveChanged', { source, createNodeActive: this.createNodeActive });
@ -641,7 +645,7 @@ export default mixins(
return;
}
this.callDebounced('saveCurrentWorkflow', 1000, undefined, true);
this.callDebounced('onSaveKeyboardShortcut', 1000);
} else if (e.key === 'Enter') {
// Activate the last selected node
const lastSelectedNode = this.lastSelectedNode;