feat(editor): Add user activation survey (#5677)

*  Add user activation survey

* Fix typo

* Avoid showing the modal when there is a modal view

* Allow to redirect to specific execution

* Improve structure

* Handle errors when sharing feedback

* update withFeatureFlag function

* Fix linting issue

* Set user activation flag on workflowExecutionCompleted event

* Revert update user settings functionality

* Remove unnecessary changes

* fix linting issue

* account for new functionality in tests

* Small improvements

* keep once instace of the model open between tabs

* Add sorting to GET /executions

* type parameters for GET /executions


a

* Add constant for local store key

* Add execution mode filtering

* fix linting issue

* Do not override settings when setting isOnboarded true

* Add update user settings endpoint

* improvements

* revert changes to /GET executions

* Fix typo

* Add userActivated flag to user store

* Add E2E test

* Fix linting issue

* Update pnpm-lock

* Revert unnecessary change

* Centralize user's settings update

* Remove unused ref in userActivationSurvey modal

* Use aliased imports

* Use createEventBus function in component

* Fix tests
This commit is contained in:
Ricardo Espinoza
2023-04-11 12:43:47 -04:00
committed by GitHub
parent 7119bde029
commit 725393dae6
30 changed files with 548 additions and 19 deletions

View File

@@ -0,0 +1,61 @@
import { WorkflowPage, NDV, MainSidebar, UserActivationSurveyModal } from '../pages';
import SettingsWithActivationModalEnabled from '../fixtures/Settings_user_activation_modal_enabled.json';
import { v4 as uuid } from 'uuid';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
const mainSidebar = new MainSidebar();
const userActivationSurveyModal = new UserActivationSurveyModal();
const BASE_WEBHOOK_URL = 'http://localhost:5678/webhook';
describe('User activation survey', () => {
it('Should show activation survey', () => {
cy.resetAll();
cy.skipSetup();
cy.intercept('GET', '/rest/settings',(req) => {
req.reply(SettingsWithActivationModalEnabled);
});
const path = uuid();
const method = 'GET';
workflowPage.actions.addInitialNodeToCanvas('Webhook');
workflowPage.actions.openNode('Webhook');
//input http method
cy.getByTestId('parameter-input-httpMethod').click();
cy.getByTestId('parameter-input-httpMethod')
.find('.el-select-dropdown')
.find('.option-headline')
.contains(method)
.click();
//input path method
cy.getByTestId('parameter-input-path')
.find('.parameter-input')
.find('input')
.clear()
.type(path);
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.activateWorkflow();
cy.request(method, `${BASE_WEBHOOK_URL}/${path}`).then((response) => {
expect(response.status).to.eq(200);
cy.visit('/');
cy.reload();
mainSidebar.actions.goToCredentials();
userActivationSurveyModal.getters.modalContainer().should('be.visible');
userActivationSurveyModal.getters.feedbackInput().type('testing');
userActivationSurveyModal.getters.sendFeedbackButton().click();
});
});
});

View File

@@ -0,0 +1,90 @@
{
"data": {
"endpointWebhook": "webhook",
"endpointWebhookTest": "webhook-test",
"saveDataErrorExecution": "all",
"saveDataSuccessExecution": "all",
"saveManualExecutions": false,
"executionTimeout": -1,
"maxExecutionTimeout": 3600,
"workflowCallerPolicyDefaultOption": "workflowsFromSameOwner",
"timezone": "America/New_York",
"urlBaseWebhook": "http://localhost:5678/",
"urlBaseEditor": "http://localhost:5678",
"versionCli": "0.221.2",
"oauthCallbackUrls": {
"oauth1": "http://localhost:5678/rest/oauth1-credential/callback",
"oauth2": "http://localhost:5678/rest/oauth2-credential/callback"
},
"versionNotifications": {
"enabled": true,
"endpoint": "https://api.n8n.io/api/versions/",
"infoUrl": "https://docs.n8n.io/getting-started/installation/updating.html"
},
"instanceId": "c229842c6d1e217486d04caf7223758e08385156ca87a58286c850760c7161f4",
"telemetry": {
"enabled": true
},
"posthog": {
"enabled": false,
"apiHost": "https://ph.n8n.io",
"apiKey": "phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo",
"autocapture": false,
"disableSessionRecording": true,
"debug": false
},
"personalizationSurveyEnabled": false,
"userActivationSurveyEnabled": true,
"defaultLocale": "en",
"userManagement": {
"enabled": true,
"showSetupOnFirstLoad": false,
"smtpSetup": false
},
"sso": {
"saml": {
"loginEnabled": false,
"loginLabel": ""
},
"ldap": {
"loginEnabled": false,
"loginLabel": ""
}
},
"publicApi": {
"enabled": false,
"latestVersion": 1,
"path": "api",
"swaggerUi": {
"enabled": true
}
},
"workflowTagsDisabled": false,
"logLevel": "info",
"hiringBannerEnabled": true,
"templates": {
"enabled": true,
"host": "https://api.n8n.io/api/"
},
"onboardingCallPromptEnabled": true,
"executionMode": "regular",
"pushBackend": "sse",
"communityNodesEnabled": true,
"deployment": {
"type": "default"
},
"isNpmAvailable": false,
"allowedModules": {},
"enterprise": {
"sharing": true,
"ldap": true,
"saml": false,
"logStreaming": false,
"advancedExecutionFilters": false
},
"hideUsagePage": false,
"license": {
"environment": "production"
}
}
}

View File

@@ -1,3 +1,5 @@
export * from './credentials-modal'; export * from './credentials-modal';
export * from './message-box'; export * from './message-box';
export * from './workflow-sharing-modal'; export * from './workflow-sharing-modal';
export * from './user-activation-survey-modal';

View File

@@ -0,0 +1,9 @@
import { BasePage } from './../base';
export class UserActivationSurveyModal extends BasePage {
getters = {
modalContainer: () => cy.getByTestId('userActivationSurvey-modal').last(),
feedbackInput: () => cy.getByTestId('activation-feedback-input').find('textarea'),
sendFeedbackButton: () => cy.getByTestId('send-activation-feedback-button'),
};
}

View File

@@ -489,6 +489,7 @@ export interface IN8nUISettings {
debug: boolean; debug: boolean;
}; };
personalizationSurveyEnabled: boolean; personalizationSurveyEnabled: boolean;
userActivationSurveyEnabled: boolean;
defaultLocale: string; defaultLocale: string;
userManagement: IUserManagementSettings; userManagement: IUserManagementSettings;
sso: { sso: {
@@ -547,6 +548,9 @@ export interface IPersonalizationSurveyAnswers {
export interface IUserSettings { export interface IUserSettings {
isOnboarded?: boolean; isOnboarded?: boolean;
showUserActivationSurvey?: boolean;
firstSuccessfulWorkflowId?: string;
userActivated?: boolean;
} }
export interface IUserManagementSettings { export interface IUserManagementSettings {
@@ -855,6 +859,7 @@ export interface PublicUser {
globalRole?: Role; globalRole?: Role;
signInType: AuthProviderType; signInType: AuthProviderType;
disabled: boolean; disabled: boolean;
settings?: IUserSettings | null;
inviteAcceptUrl?: string; inviteAcceptUrl?: string;
} }

View File

@@ -262,6 +262,8 @@ class Server extends AbstractServer {
}, },
personalizationSurveyEnabled: personalizationSurveyEnabled:
config.getEnv('personalization.enabled') && config.getEnv('diagnostics.enabled'), config.getEnv('personalization.enabled') && config.getEnv('diagnostics.enabled'),
userActivationSurveyEnabled:
config.getEnv('userActivationSurvey.enabled') && config.getEnv('diagnostics.enabled'),
defaultLocale: config.getEnv('defaultLocale'), defaultLocale: config.getEnv('defaultLocale'),
userManagement: { userManagement: {
enabled: isUserManagementEnabled(), enabled: isUserManagementEnabled(),
@@ -364,7 +366,6 @@ class Server extends AbstractServer {
if (config.get('nodes.packagesMissing').length > 0) { if (config.get('nodes.packagesMissing').length > 0) {
this.frontendSettings.missingPackages = true; this.frontendSettings.missingPackages = true;
} }
return this.frontendSettings; return this.frontendSettings;
} }

View File

@@ -179,7 +179,6 @@ export async function withFeatureFlags(
const fetchPromise = new Promise<CurrentUser>(async (resolve) => { const fetchPromise = new Promise<CurrentUser>(async (resolve) => {
user.featureFlags = await postHog.getFeatureFlags(user); user.featureFlags = await postHog.getFeatureFlags(user);
resolve(user); resolve(user);
}); });

View File

@@ -34,6 +34,7 @@ import { whereClause } from '@/UserManagement/UserManagementHelper';
import omit from 'lodash.omit'; import omit from 'lodash.omit';
import { PermissionChecker } from './UserManagement/PermissionChecker'; import { PermissionChecker } from './UserManagement/PermissionChecker';
import { isWorkflowIdValid } from './utils'; import { isWorkflowIdValid } from './utils';
import { UserService } from './user/user.service';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@@ -429,7 +430,7 @@ export async function isBelowOnboardingThreshold(user: User): Promise<boolean> {
// user is above threshold --> set flag in settings // user is above threshold --> set flag in settings
if (!belowThreshold) { if (!belowThreshold) {
void Db.collections.User.update(user.id, { settings: { isOnboarded: true } }); void UserService.updateUserSettings(user.id, { isOnboarded: true });
} }
return belowThreshold; return belowThreshold;

View File

@@ -1042,6 +1042,15 @@ export const schema = {
}, },
}, },
userActivationSurvey: {
enabled: {
doc: 'Whether user activation survey is enabled.',
format: Boolean,
default: true,
env: 'N8N_USER_ACTIVATION_SURVEY_ENABLED',
},
},
diagnostics: { diagnostics: {
enabled: { enabled: {
doc: 'Whether diagnostic mode is enabled.', doc: 'Whether diagnostic mode is enabled.',

View File

@@ -14,7 +14,12 @@ import { issueCookie } from '@/auth/jwt';
import { Response } from 'express'; import { Response } from 'express';
import type { Repository } from 'typeorm'; import type { Repository } from 'typeorm';
import type { ILogger } from 'n8n-workflow'; import type { ILogger } from 'n8n-workflow';
import { AuthenticatedRequest, MeRequest, UserUpdatePayload } from '@/requests'; import {
AuthenticatedRequest,
MeRequest,
UserSettingsUpdatePayload,
UserUpdatePayload,
} from '@/requests';
import type { import type {
PublicUser, PublicUser,
IDatabaseCollections, IDatabaseCollections,
@@ -23,6 +28,7 @@ import type {
} from '@/Interfaces'; } from '@/Interfaces';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers'; import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers';
import { UserService } from '@/user/user.service';
@RestController('/me') @RestController('/me')
export class MeController { export class MeController {
@@ -52,7 +58,7 @@ export class MeController {
} }
/** /**
* Update the logged-in user's settings, except password. * Update the logged-in user's properties, except password.
*/ */
@Patch('/') @Patch('/')
async updateCurrentUser(req: MeRequest.UserUpdate, res: Response): Promise<PublicUser> { async updateCurrentUser(req: MeRequest.UserUpdate, res: Response): Promise<PublicUser> {
@@ -234,4 +240,22 @@ export class MeController {
return { success: true }; return { success: true };
} }
/**
* Update the logged-in user's settings.
*/
@Patch('/settings')
async updateCurrentUserSettings(req: MeRequest.UserSettingsUpdate): Promise<User['settings']> {
const payload = plainToInstance(UserSettingsUpdatePayload, req.body);
const { id } = req.user;
await UserService.updateUserSettings(id, payload);
const user = await this.userRepository.findOneOrFail({
select: ['settings'],
where: { id },
});
return user.settings;
}
} }

View File

@@ -7,6 +7,7 @@ import { QueryFailedError } from 'typeorm';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import config from '@/config'; import config from '@/config';
import { UserService } from '@/user/user.service';
enum StatisticsUpsertResult { enum StatisticsUpsertResult {
insert = 'insert', insert = 'insert',
@@ -112,6 +113,15 @@ export async function workflowExecutionCompleted(
user_id: owner.id, user_id: owner.id,
workflow_id: workflowId, workflow_id: workflowId,
}; };
if (!owner.settings?.firstSuccessfulWorkflowId) {
await UserService.updateUserSettings(owner.id, {
firstSuccessfulWorkflowId: workflowId,
userActivated: true,
showUserActivationSurvey: true,
});
}
// Send the metrics // Send the metrics
await Container.get(InternalHooks).onFirstProductionWorkflowSuccess(metrics); await Container.get(InternalHooks).onFirstProductionWorkflowSuccess(metrics);
} }

View File

@@ -304,7 +304,7 @@ export class ExecutionsService {
}); });
} }
// Omit `data` from the Execution since it is the largest and not necesary for the list. // Omit `data` from the Execution since it is the largest and not necessary for the list.
let query = Db.collections.Execution.createQueryBuilder('execution') let query = Db.collections.Execution.createQueryBuilder('execution')
.select([ .select([
'execution.id', 'execution.id',

View File

@@ -10,7 +10,7 @@ import type {
IWorkflowSettings, IWorkflowSettings,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { IsEmail, IsString, Length } from 'class-validator'; import { IsBoolean, IsEmail, IsOptional, IsString, Length } from 'class-validator';
import { NoXss } from '@db/utils/customValidators'; import { NoXss } from '@db/utils/customValidators';
import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces'; import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
@@ -31,6 +31,15 @@ export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'la
@Length(1, 32, { message: 'Last name must be $constraint1 to $constraint2 characters long.' }) @Length(1, 32, { message: 'Last name must be $constraint1 to $constraint2 characters long.' })
lastName: string; lastName: string;
} }
export class UserSettingsUpdatePayload {
@IsBoolean({ message: 'showUserActivationSurvey should be a boolean' })
@IsOptional()
showUserActivationSurvey: boolean;
@IsBoolean({ message: 'userActivated should be a boolean' })
@IsOptional()
userActivated: boolean;
}
export type AuthlessRequest< export type AuthlessRequest<
RouteParams = {}, RouteParams = {},
@@ -161,6 +170,7 @@ export declare namespace ExecutionRequest {
// ---------------------------------- // ----------------------------------
export declare namespace MeRequest { export declare namespace MeRequest {
export type UserSettingsUpdate = AuthenticatedRequest<{}, {}, UserSettingsUpdatePayload>;
export type UserUpdate = AuthenticatedRequest<{}, {}, UserUpdatePayload>; export type UserUpdate = AuthenticatedRequest<{}, {}, UserUpdatePayload>;
export type Password = AuthenticatedRequest< export type Password = AuthenticatedRequest<
{}, {},

View File

@@ -2,6 +2,7 @@ import type { EntityManager, FindOptionsWhere } from 'typeorm';
import { In } from 'typeorm'; import { In } from 'typeorm';
import * as Db from '@/Db'; import * as Db from '@/Db';
import { User } from '@db/entities/User'; import { User } from '@db/entities/User';
import type { IUserSettings } from '@/Interfaces';
export class UserService { export class UserService {
static async get(where: FindOptionsWhere<User>): Promise<User | null> { static async get(where: FindOptionsWhere<User>): Promise<User | null> {
@@ -14,4 +15,11 @@ export class UserService {
static async getByIds(transaction: EntityManager, ids: string[]) { static async getByIds(transaction: EntityManager, ids: string[]) {
return transaction.find(User, { where: { id: In(ids) } }); return transaction.find(User, { where: { id: In(ids) } });
} }
static async updateUserSettings(id: string, userSettings: Partial<IUserSettings>) {
const { settings: currentSettings } = await Db.collections.User.findOneOrFail({
where: { id },
});
return Db.collections.User.update(id, { settings: { ...currentSettings, ...userSettings } });
}
} }

View File

@@ -12,6 +12,7 @@ import { getLogger } from '@/Logger';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { mockInstance } from '../integration/shared/utils'; import { mockInstance } from '../integration/shared/utils';
import { UserService } from '@/user/user.service';
type WorkflowStatisticsRepository = Repository<WorkflowStatistics>; type WorkflowStatisticsRepository = Repository<WorkflowStatistics>;
jest.mock('@/Db', () => { jest.mock('@/Db', () => {
@@ -24,6 +25,8 @@ jest.mock('@/Db', () => {
}; };
}); });
jest.spyOn(UserService, 'updateUserSettings').mockImplementation();
describe('Events', () => { describe('Events', () => {
const dbType = config.getEnv('database.type'); const dbType = config.getEnv('database.type');
const fakeUser = Object.assign(new User(), { id: 'abcde-fghij' }); const fakeUser = Object.assign(new User(), { id: 'abcde-fghij' });

View File

@@ -44,6 +44,7 @@
"@jsplumb/core": "^5.13.2", "@jsplumb/core": "^5.13.2",
"@jsplumb/util": "^5.13.2", "@jsplumb/util": "^5.13.2",
"axios": "^0.21.1", "axios": "^0.21.1",
"canvas-confetti": "^1.6.0",
"codemirror-lang-html-n8n": "^1.0.0", "codemirror-lang-html-n8n": "^1.0.0",
"codemirror-lang-n8n-expression": "^0.2.0", "codemirror-lang-n8n-expression": "^0.2.0",
"dateformat": "^3.0.3", "dateformat": "^3.0.3",
@@ -89,6 +90,7 @@
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.4.3",
"@testing-library/vue": "^5.8.3", "@testing-library/vue": "^5.8.3",
"@types/canvas-confetti": "^1.6.0",
"@types/dateformat": "^3.0.0", "@types/dateformat": "^3.0.0",
"@types/express": "^4.17.6", "@types/express": "^4.17.6",
"@types/file-saver": "^2.0.1", "@types/file-saver": "^2.0.1",

View File

@@ -594,6 +594,12 @@ export interface IUserResponse {
personalizationAnswers?: IPersonalizationSurveyVersions | null; personalizationAnswers?: IPersonalizationSurveyVersions | null;
isPending: boolean; isPending: boolean;
signInType?: SignInType; signInType?: SignInType;
settings?: {
isOnboarded?: boolean;
showUserActivationSurvey?: boolean;
firstSuccessfulWorkflowId?: string;
userActivated?: boolean;
};
} }
export interface CurrentUserResponse extends IUserResponse { export interface CurrentUserResponse extends IUserResponse {
@@ -749,6 +755,7 @@ export interface IN8nUISettings {
versionNotifications: IVersionNotificationSettings; versionNotifications: IVersionNotificationSettings;
instanceId: string; instanceId: string;
personalizationSurveyEnabled: boolean; personalizationSurveyEnabled: boolean;
userActivationSurveyEnabled: boolean;
telemetry: ITelemetrySettings; telemetry: ITelemetrySettings;
userManagement: IUserManagementConfig; userManagement: IUserManagementConfig;
defaultLocale: string; defaultLocale: string;

View File

@@ -89,11 +89,23 @@ export async function changePassword(
export function updateCurrentUser( export function updateCurrentUser(
context: IRestApiContext, context: IRestApiContext,
params: { id: string; firstName: string; lastName: string; email: string }, params: {
id?: string;
firstName?: string;
lastName?: string;
email: string;
},
): Promise<IUserResponse> { ): Promise<IUserResponse> {
return makeRestApiRequest(context, 'PATCH', '/me', params as unknown as IDataObject); return makeRestApiRequest(context, 'PATCH', '/me', params as unknown as IDataObject);
} }
export function updateCurrentUserSettings(
context: IRestApiContext,
settings: IUserResponse['settings'],
): Promise<IUserResponse['settings']> {
return makeRestApiRequest(context, 'PATCH', '/me/settings', settings);
}
export function updateCurrentUserPassword( export function updateCurrentUserPassword(
context: IRestApiContext, context: IRestApiContext,
params: { newPassword: string; currentPassword: string }, params: { newPassword: string; currentPassword: string },

View File

@@ -1,5 +1,11 @@
import { IRestApiContext } from '@/Interface'; import { IExecutionsCurrentSummaryExtended, IRestApiContext } from '@/Interface';
import { IDataObject } from 'n8n-workflow'; import {
ExecutionFilters,
ExecutionOptions,
ExecutionStatus,
IDataObject,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { makeRestApiRequest } from '@/utils'; import { makeRestApiRequest } from '@/utils';
export async function getNewWorkflow(context: IRestApiContext, name?: string) { export async function getNewWorkflow(context: IRestApiContext, name?: string) {
@@ -30,8 +36,12 @@ export async function getCurrentExecutions(context: IRestApiContext, filter: IDa
return await makeRestApiRequest(context, 'GET', '/executions-current', { filter }); return await makeRestApiRequest(context, 'GET', '/executions-current', { filter });
} }
export async function getFinishedExecutions(context: IRestApiContext, filter: IDataObject) { export async function getExecutions(
return await makeRestApiRequest(context, 'GET', '/executions', { filter }); context: IRestApiContext,
filter?: ExecutionFilters,
options?: ExecutionOptions,
): Promise<{ count: number; results: IExecutionsCurrentSummaryExtended[]; estimated: boolean }> {
return await makeRestApiRequest(context, 'GET', '/executions', { filter, ...options });
} }
export async function getExecutionData(context: IRestApiContext, executionId: string) { export async function getExecutionData(context: IRestApiContext, executionId: string) {

View File

@@ -87,6 +87,10 @@
<ImportCurlModal /> <ImportCurlModal />
</ModalRoot> </ModalRoot>
<ModalRoot :name="USER_ACTIVATION_SURVEY_MODAL">
<WorkflowSuccessModal />
</ModalRoot>
<ModalRoot :name="COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY"> <ModalRoot :name="COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY">
<template #default="{ modalName, activeId, mode }"> <template #default="{ modalName, activeId, mode }">
<CommunityPackageManageConfirmModal <CommunityPackageManageConfirmModal
@@ -134,6 +138,7 @@ import {
WORKFLOW_SHARE_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY,
IMPORT_CURL_MODAL_KEY, IMPORT_CURL_MODAL_KEY,
LOG_STREAM_MODAL_KEY, LOG_STREAM_MODAL_KEY,
USER_ACTIVATION_SURVEY_MODAL,
} from '@/constants'; } from '@/constants';
import AboutModal from './AboutModal.vue'; import AboutModal from './AboutModal.vue';
@@ -157,6 +162,7 @@ import ExecutionsModal from './ExecutionsModal.vue';
import ActivationModal from './ActivationModal.vue'; import ActivationModal from './ActivationModal.vue';
import ImportCurlModal from './ImportCurlModal.vue'; import ImportCurlModal from './ImportCurlModal.vue';
import WorkflowShareModal from './WorkflowShareModal.ee.vue'; import WorkflowShareModal from './WorkflowShareModal.ee.vue';
import WorkflowSuccessModal from './UserActivationSurveyModal.vue';
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue'; import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
export default Vue.extend({ export default Vue.extend({
@@ -184,6 +190,7 @@ export default Vue.extend({
WorkflowShareModal, WorkflowShareModal,
ImportCurlModal, ImportCurlModal,
EventDestinationSettingsModal, EventDestinationSettingsModal,
WorkflowSuccessModal,
}, },
data: () => ({ data: () => ({
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
@@ -207,6 +214,7 @@ export default Vue.extend({
WORKFLOW_ACTIVE_MODAL_KEY, WORKFLOW_ACTIVE_MODAL_KEY,
IMPORT_CURL_MODAL_KEY, IMPORT_CURL_MODAL_KEY,
LOG_STREAM_MODAL_KEY, LOG_STREAM_MODAL_KEY,
USER_ACTIVATION_SURVEY_MODAL,
}), }),
}); });
</script> </script>

View File

@@ -0,0 +1,174 @@
<template>
<Modal
width="500px"
:title="locale.baseText('userActivationSurveyModal.title')"
:eventBus="modalBus"
:name="USER_ACTIVATION_SURVEY_MODAL"
:center="true"
:beforeClose="beforeClosingModal"
>
<template #content>
<div :class="$style.container">
<div :class="$style.description">
<i18n path="userActivationSurveyModal.description.workflowRan">
<template #workflow>
<n8n-text :bold="true"> {{ workflowName }} </n8n-text>
</template>
<template #ranSuccessfully>
<n8n-text :bold="true" :class="$style.link">
{{
locale.baseText('userActivationSurveyModal.description.workflowRanSuccessfully')
}}
</n8n-text>
</template>
<template #savedTime>
<n8n-text>
{{ locale.baseText('userActivationSurveyModal.description.savedTime') }}
</n8n-text>
</template>
</i18n>
</div>
<div :class="$style.form">
<n8n-input-label
:label="$locale.baseText('userActivationSurveyModal.form.label')"
color="text-dark"
>
<n8n-input
type="textarea"
:maxlength="FEEDBACK_MAX_LENGTH"
:rows="3"
v-model="feedback"
@input="onInput"
data-test-id="activation-feedback-input"
/>
</n8n-input-label>
</div>
</div>
</template>
<template #footer>
<div :class="$style.modalFooter">
<n8n-button
:disabled="!hasAnyChanges"
@click="onShareFeedback"
size="large"
float="right"
:label="locale.baseText('userActivationSurveyModal.form.button.shareFeedback')"
data-test-id="send-activation-feedback-button"
/>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import Modal from '@/components/Modal.vue';
import { USER_ACTIVATION_SURVEY_MODAL } from '@/constants';
import { useUsersStore } from '@/stores/users';
import confetti from 'canvas-confetti';
import { telemetry } from '@/plugins/telemetry';
import { i18n as locale } from '@/plugins/i18n';
import { Notification } from 'element-ui';
import { useWorkflowsStore } from '@/stores/workflows';
import { createEventBus } from '@/event-bus';
const FEEDBACK_MAX_LENGTH = 300;
const userStore = useUsersStore();
const workflowStore = useWorkflowsStore();
const hasAnyChanges = ref(false);
const feedback = ref('');
const modalBus = createEventBus();
const workflowName = ref('');
onMounted(async () => {
const currentSettings = getCurrentSettings();
try {
const { name } = await workflowStore.fetchWorkflow(
currentSettings?.firstSuccessfulWorkflowId ?? '',
);
workflowName.value = name;
showConfetti();
} catch (e) {}
});
const onShareFeedback = () => {
telemetry.track('User responded to activation modal', { response: getFeedback() });
showSharedFeedbackSuccess();
modalBus.emit('close');
};
const getCurrentSettings = () => {
return userStore.currentUser?.settings;
};
const getFeedback = () => {
return feedback.value.slice(0, FEEDBACK_MAX_LENGTH);
};
const beforeClosingModal = async () => {
const currentUser = userStore.currentUser;
if (currentUser) {
try {
await userStore.updateUserSettings({ showUserActivationSurvey: false });
} catch {
showSharedFeedbackError();
return false;
}
}
return true;
};
const onInput = () => {
hasAnyChanges.value = true;
};
const showSharedFeedbackSuccess = () => {
Notification.success({
title: locale.baseText('userActivationSurveyModal.sharedFeedback.success'),
message: '',
position: 'bottom-right',
});
};
const showSharedFeedbackError = () => {
Notification.error({
title: locale.baseText('userActivationSurveyModal.sharedFeedback.error'),
message: '',
position: 'bottom-right',
});
};
const showConfetti = () => {
confetti({
particleCount: 200,
spread: 100,
origin: { y: 0.6 },
zIndex: 2050,
colors: ['5C4EC2', 'D7E6F1', 'FF9284', '8D7FED', 'B8AFF9', 'FF6D5A'],
});
};
</script>
<style module lang="scss">
.form {
margin-top: var(--spacing-l);
}
.link {
color: var(--color-primary);
}
.description > * {
font-size: var(--font-size-s);
}
.container > * {
margin-bottom: var(--spacing-s);
&:last-child {
margin-bottom: 0;
}
}
</style>

View File

@@ -46,6 +46,7 @@ export const COMMUNITY_PACKAGE_INSTALL_MODAL_KEY = 'communityPackageInstall';
export const COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY = 'communityPackageManageConfirm'; export const COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY = 'communityPackageManageConfirm';
export const IMPORT_CURL_MODAL_KEY = 'importCurl'; export const IMPORT_CURL_MODAL_KEY = 'importCurl';
export const LOG_STREAM_MODAL_KEY = 'settingsLogStream'; export const LOG_STREAM_MODAL_KEY = 'settingsLogStream';
export const USER_ACTIVATION_SURVEY_MODAL = 'userActivationSurvey';
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = { export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
UNINSTALL: 'uninstall', UNINSTALL: 'uninstall',
@@ -325,6 +326,7 @@ export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG = 'N8N_PIN_DATA_DISCOVERY
export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS'; export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS';
export const LOCAL_STORAGE_MAPPING_IS_ONBOARDED = 'N8N_MAPPING_ONBOARDED'; export const LOCAL_STORAGE_MAPPING_IS_ONBOARDED = 'N8N_MAPPING_ONBOARDED';
export const LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH = 'N8N_MAIN_PANEL_RELATIVE_WIDTH'; export const LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH = 'N8N_MAIN_PANEL_RELATIVE_WIDTH';
export const LOCAL_STORAGE_ACTIVE_MODAL = 'N8N_ACTIVE_MODAL';
export const LOCAL_STORAGE_THEME = 'N8N_THEME'; export const LOCAL_STORAGE_THEME = 'N8N_THEME';
export const LOCAL_STORAGE_EXPERIMENT_OVERRIDES = 'N8N_EXPERIMENT_OVERRIDES'; export const LOCAL_STORAGE_EXPERIMENT_OVERRIDES = 'N8N_EXPERIMENT_OVERRIDES';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename='; export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';

View File

@@ -26,6 +26,9 @@ import { I18nPlugin, i18nInstance } from './plugins/i18n';
import { createPinia, PiniaVuePlugin } from 'pinia'; import { createPinia, PiniaVuePlugin } from 'pinia';
import { useWebhooksStore } from './stores/webhooks'; import { useWebhooksStore } from './stores/webhooks';
import { useUsersStore } from './stores/users';
import { VIEWS } from '@/constants';
import { useUIStore } from './stores/ui';
Vue.config.productionTip = false; Vue.config.productionTip = false;
@@ -44,6 +47,12 @@ new Vue({
router.afterEach((to, from) => { router.afterEach((to, from) => {
runExternalHook('main.routeChange', useWebhooksStore(), { from, to }); runExternalHook('main.routeChange', useWebhooksStore(), { from, to });
const userStore = useUsersStore();
if (userStore.currentUser && to.name && to.name !== VIEWS.SIGNOUT && !to.name.includes('Modal')) {
setTimeout(() => {
userStore.showUserActivationSurveyModal();
}, 500);
}
}); });
if (!import.meta.env.PROD) { if (!import.meta.env.PROD) {

View File

@@ -1740,6 +1740,14 @@
"settings.sso.actionBox.title": "Available on Enterprise plan", "settings.sso.actionBox.title": "Available on Enterprise plan",
"settings.sso.actionBox.description": "Use Single Sign On to consolidate authentication into a single platform to improve security and agility.", "settings.sso.actionBox.description": "Use Single Sign On to consolidate authentication into a single platform to improve security and agility.",
"settings.sso.actionBox.buttonText": "See plans", "settings.sso.actionBox.buttonText": "See plans",
"userActivationSurveyModal.title": "Congrats! Your workflow ran automatically 👏",
"userActivationSurveyModal.description.workflowRan": "{workflow} {ranSuccessfully} {savedTime}",
"userActivationSurveyModal.description.workflowRanSuccessfully": "ran successfully",
"userActivationSurveyModal.description.savedTime": "for the first time. Looks like n8n just saved you some time! Can you help us make n8n even better and answer the following question?",
"userActivationSurveyModal.form.label": "What almost stopped you from creating this workflow?",
"userActivationSurveyModal.form.button.shareFeedback": "Share feedback",
"userActivationSurveyModal.sharedFeedback.success": "Thanks for your feedback",
"userActivationSurveyModal.sharedFeedback.error": "Problem sharing feedback, try again",
"sso.login.divider": "or", "sso.login.divider": "or",
"sso.login.button": "Continue with SSO" "sso.login.button": "Continue with SSO"
} }

View File

@@ -127,6 +127,13 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
this.settings.personalizationSurveyEnabled this.settings.personalizationSurveyEnabled
); );
}, },
isUserActivationSurveyEnabled(): boolean {
return (
this.settings.telemetry &&
this.settings.telemetry.enabled &&
this.settings.userActivationSurveyEnabled
);
},
telemetry(): ITelemetrySettings { telemetry(): ITelemetrySettings {
return this.settings.telemetry; return this.settings.telemetry;
}, },

View File

@@ -29,6 +29,7 @@ import {
WORKFLOW_ACTIVE_MODAL_KEY, WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY,
USER_ACTIVATION_SURVEY_MODAL,
} from '@/constants'; } from '@/constants';
import { import {
CurlToJSONResponse, CurlToJSONResponse,
@@ -124,6 +125,9 @@ export const useUIStore = defineStore(STORES.UI, {
activeId: null, activeId: null,
showAuthSelector: false, showAuthSelector: false,
}, },
[USER_ACTIVATION_SURVEY_MODAL]: {
open: false,
},
}, },
modalStack: [], modalStack: [],
sidebarMenuCollapsed: true, sidebarMenuCollapsed: true,

View File

@@ -16,10 +16,16 @@ import {
submitPersonalizationSurvey, submitPersonalizationSurvey,
updateCurrentUser, updateCurrentUser,
updateCurrentUserPassword, updateCurrentUserPassword,
updateCurrentUserSettings,
validatePasswordToken, validatePasswordToken,
validateSignupToken, validateSignupToken,
} from '@/api/users'; } from '@/api/users';
import { PERSONALIZATION_MODAL_KEY, STORES } from '@/constants'; import {
PERSONALIZATION_MODAL_KEY,
USER_ACTIVATION_SURVEY_MODAL,
STORES,
LOCAL_STORAGE_ACTIVE_MODAL,
} from '@/constants';
import type { import type {
ICredentialsResponse, ICredentialsResponse,
IInviteResponse, IInviteResponse,
@@ -55,6 +61,9 @@ export const useUsersStore = defineStore(STORES.USERS, {
allUsers(): IUser[] { allUsers(): IUser[] {
return Object.values(this.users); return Object.values(this.users);
}, },
userActivated(): boolean {
return Boolean(this.currentUser?.settings?.userActivated);
},
currentUser(): IUser | null { currentUser(): IUser | null {
return this.currentUserId ? this.users[this.currentUserId] : null; return this.currentUserId ? this.users[this.currentUserId] : null;
}, },
@@ -236,6 +245,17 @@ export const useUsersStore = defineStore(STORES.USERS, {
const user = await updateCurrentUser(rootStore.getRestApiContext, params); const user = await updateCurrentUser(rootStore.getRestApiContext, params);
this.addUsers([user]); this.addUsers([user]);
}, },
async updateUserSettings(settings: IUserResponse['settings']): Promise<void> {
const rootStore = useRootStore();
const updatedSettings = await updateCurrentUserSettings(
rootStore.getRestApiContext,
settings,
);
if (this.currentUser) {
this.currentUser.settings = updatedSettings;
this.addUsers([this.currentUser]);
}
},
async updateCurrentUserPassword({ async updateCurrentUserPassword({
password, password,
currentPassword, currentPassword,
@@ -287,6 +307,16 @@ export const useUsersStore = defineStore(STORES.USERS, {
uiStore.openModal(PERSONALIZATION_MODAL_KEY); uiStore.openModal(PERSONALIZATION_MODAL_KEY);
} }
}, },
async showUserActivationSurveyModal() {
const settingsStore = useSettingsStore();
if (settingsStore.isUserActivationSurveyEnabled) {
const currentUser = this.currentUser;
if (currentUser?.settings?.showUserActivationSurvey) {
const uiStore = useUIStore();
uiStore.openModal(USER_ACTIVATION_SURVEY_MODAL);
}
}
},
async skipOwnerSetup(): Promise<void> { async skipOwnerSetup(): Promise<void> {
try { try {
const rootStore = useRootStore(); const rootStore = useRootStore();

View File

@@ -51,7 +51,7 @@ import {
getActiveWorkflows, getActiveWorkflows,
getCurrentExecutions, getCurrentExecutions,
getExecutionData, getExecutionData,
getFinishedExecutions, getExecutions,
getNewWorkflow, getNewWorkflow,
getWorkflow, getWorkflow,
getWorkflows, getWorkflows,
@@ -942,7 +942,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
requestFilter: ExecutionsQueryFilter, requestFilter: ExecutionsQueryFilter,
): Promise<IExecutionsSummary[]> { ): Promise<IExecutionsSummary[]> {
let activeExecutions = []; let activeExecutions = [];
let finishedExecutions = [];
if (!requestFilter.workflowId) { if (!requestFilter.workflowId) {
return []; return [];
@@ -954,10 +953,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
workflowId: requestFilter.workflowId, workflowId: requestFilter.workflowId,
}); });
} }
finishedExecutions = await getFinishedExecutions( const finishedExecutions = await getExecutions(rootStore.getRestApiContext, requestFilter);
rootStore.getRestApiContext,
requestFilter,
);
this.finishedExecutionsCount = finishedExecutions.count; this.finishedExecutionsCount = finishedExecutions.count;
return [...activeExecutions, ...(finishedExecutions.results || [])]; return [...activeExecutions, ...(finishedExecutions.results || [])];
} catch (error) { } catch (error) {

View File

@@ -1919,3 +1919,17 @@ export interface IExceutionSummaryNodeExecutionResult {
description?: string; description?: string;
}>; }>;
} }
export interface ExecutionOptions {
limit?: number;
}
export interface ExecutionFilters {
finished?: boolean;
mode?: WorkflowExecuteMode[];
retryOf?: string;
retrySuccessId?: string;
status?: ExecutionStatus[];
waitTill?: boolean;
workflowId?: number | string;
}

14
pnpm-lock.yaml generated
View File

@@ -871,6 +871,9 @@ importers:
axios: axios:
specifier: ^0.21.1 specifier: ^0.21.1
version: 0.21.4(debug@4.3.2) version: 0.21.4(debug@4.3.2)
canvas-confetti:
specifier: ^1.6.0
version: 1.6.0
codemirror-lang-html-n8n: codemirror-lang-html-n8n:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0 version: 1.0.0
@@ -1001,6 +1004,9 @@ importers:
'@testing-library/vue': '@testing-library/vue':
specifier: ^5.8.3 specifier: ^5.8.3
version: 5.8.3(vue-template-compiler@2.7.14)(vue@2.7.14) version: 5.8.3(vue-template-compiler@2.7.14)(vue@2.7.14)
'@types/canvas-confetti':
specifier: ^1.6.0
version: 1.6.0
'@types/dateformat': '@types/dateformat':
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.1 version: 3.0.1
@@ -6017,6 +6023,10 @@ packages:
'@types/connect': 3.4.35 '@types/connect': 3.4.35
'@types/node': 16.18.12 '@types/node': 16.18.12
/@types/canvas-confetti@1.6.0:
resolution: {integrity: sha512-Yq6rIccwcco0TLD5SMUrIM7Fk7Fe/C0jmNRxJJCLtAF6gebDkPuUjK5EHedxecm69Pi/aA+It39Ux4OHmFhjRw==}
dev: true
/@types/caseless@0.12.2: /@types/caseless@0.12.2:
resolution: {integrity: sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==} resolution: {integrity: sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==}
dev: true dev: true
@@ -8897,6 +8907,10 @@ packages:
resolution: {integrity: sha512-8sdQIdMztYmzfTMO6KfLny878Ln9c2M0fc7EH60IjlP4Dc4PiCy7K2Vl3ITmWgOyPgVQKa5x+UP/KqFsxj4mBg==} resolution: {integrity: sha512-8sdQIdMztYmzfTMO6KfLny878Ln9c2M0fc7EH60IjlP4Dc4PiCy7K2Vl3ITmWgOyPgVQKa5x+UP/KqFsxj4mBg==}
dev: true dev: true
/canvas-confetti@1.6.0:
resolution: {integrity: sha512-ej+w/m8Jzpv9Z7W7uJZer14Ke8P2ogsjg4ZMGIuq4iqUOqY2Jq8BNW42iGmNfRwREaaEfFIczLuZZiEVSYNHAA==}
dev: false
/capital-case@1.0.4: /capital-case@1.0.4:
resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
dependencies: dependencies: