feat: Update NPS Value Survey (#9638)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
Mutasem Aldmour 2024-06-11 10:23:30 +02:00 committed by GitHub
parent aaa78435b0
commit 50bd5b9080
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1416 additions and 497 deletions

View file

@ -1,4 +1,5 @@
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { successToast } from '../pages/notifications';
import {
MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
@ -166,8 +167,8 @@ describe('Canvas Actions', () => {
.findChildByTestId('execute-node-button')
.click({ force: true });
WorkflowPage.actions.executeNode(CODE_NODE_NAME);
WorkflowPage.getters.successToast().should('have.length', 2);
WorkflowPage.getters.successToast().should('contain.text', 'Node executed successfully');
successToast().should('have.length', 2);
successToast().should('contain.text', 'Node executed successfully');
});
it('should disable and enable node', () => {
@ -201,10 +202,10 @@ describe('Canvas Actions', () => {
WorkflowPage.actions.selectAll();
WorkflowPage.actions.hitCopy();
WorkflowPage.getters.successToast().should('contain', 'Copied!');
successToast().should('contain', 'Copied!');
WorkflowPage.actions.copyNode(CODE_NODE_NAME);
WorkflowPage.getters.successToast().should('contain', 'Copied!');
successToast().should('contain', 'Copied!');
});
it('should select/deselect all nodes', () => {

View file

@ -6,6 +6,7 @@ import {
BACKEND_BASE_URL,
} from '../constants';
import { WorkflowPage, NDV } from '../pages';
import { errorToast } from '../pages/notifications';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
@ -139,9 +140,7 @@ describe('Data pinning', () => {
test: '1'.repeat(Cypress.env('MAX_PINNED_DATA_SIZE') as number),
},
]);
workflowPage.getters
.errorToast()
.should('contain', 'Workflow has reached the maximum allowed pinned data size');
errorToast().should('contain', 'Workflow has reached the maximum allowed pinned data size');
});
it('Should show an error when pin data JSON in invalid', () => {
@ -152,7 +151,7 @@ describe('Data pinning', () => {
ndv.getters.editPinnedDataButton().should('be.visible');
ndv.actions.setPinnedData('[ { "name": "First item", "code": 2dsa }]');
workflowPage.getters.errorToast().should('contain', 'Unable to save due to invalid JSON');
errorToast().should('contain', 'Unable to save due to invalid JSON');
});
it('Should be able to reference paired items in a node located before pinned data', () => {

View file

@ -1,5 +1,6 @@
import { v4 as uuid } from 'uuid';
import { NDV, WorkflowPage as WorkflowPageClass } from '../pages';
import { successToast } from '../pages/notifications';
const workflowPage = new WorkflowPageClass();
const ndv = new NDV();
@ -16,7 +17,7 @@ describe('ADO-1338-ndv-missing-input-panel', () => {
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click();
// Check success toast (works because Cypress waits enough for the element to show after the http request node has finished)
workflowPage.getters.successToast().should('be.visible');
successToast().should('be.visible');
workflowPage.actions.openNode('Discourse1');
ndv.getters.inputPanel().should('be.visible');

View file

@ -1,7 +1,8 @@
import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants';
import { MainSidebar, SettingsSidebar, SettingsUsersPage, WorkflowPage } from '../pages';
import { MainSidebar, SettingsSidebar, SettingsUsersPage } from '../pages';
import { PersonalSettingsPage } from '../pages/settings-personal';
import { getVisibleSelect } from '../utils';
import { errorToast, successToast } from '../pages/notifications';
/**
* User A - Instance owner
@ -24,7 +25,6 @@ const updatedPersonalData = {
};
const usersSettingsPage = new SettingsUsersPage();
const workflowPage = new WorkflowPage();
const personalSettingsPage = new PersonalSettingsPage();
const settingsSidebar = new SettingsSidebar();
const mainSidebar = new MainSidebar();
@ -174,7 +174,7 @@ describe('User Management', { disableAutoLogin: true }, () => {
usersSettingsPage.getters.deleteDataRadioButton().click();
usersSettingsPage.getters.deleteDataInput().type('delete all data');
usersSettingsPage.getters.deleteUserButton().click();
workflowPage.getters.successToast().should('contain', 'User deleted');
successToast().should('contain', 'User deleted');
});
it('should delete user and transfer their data', () => {
@ -184,7 +184,7 @@ describe('User Management', { disableAutoLogin: true }, () => {
usersSettingsPage.getters.userSelectDropDown().click();
usersSettingsPage.getters.userSelectOptions().first().click();
usersSettingsPage.getters.deleteUserButton().click();
workflowPage.getters.successToast().should('contain', 'User deleted');
successToast().should('contain', 'User deleted');
});
it('should allow user to change their personal data', () => {
@ -196,7 +196,7 @@ describe('User Management', { disableAutoLogin: true }, () => {
personalSettingsPage.getters
.currentUserName()
.should('contain', `${updatedPersonalData.newFirstName} ${updatedPersonalData.newLastName}`);
workflowPage.getters.successToast().should('contain', 'Personal details updated');
successToast().should('contain', 'Personal details updated');
});
it("shouldn't allow user to set weak password", () => {
@ -211,10 +211,7 @@ describe('User Management', { disableAutoLogin: true }, () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
personalSettingsPage.getters.changePasswordLink().click();
personalSettingsPage.actions.updatePassword('iCannotRemember', updatedPersonalData.newPassword);
workflowPage.getters
.errorToast()
.closest('div')
.should('contain', 'Provided current password is incorrect.');
errorToast().closest('div').should('contain', 'Provided current password is incorrect.');
});
it('should change current user password', () => {
@ -224,7 +221,7 @@ describe('User Management', { disableAutoLogin: true }, () => {
INSTANCE_OWNER.password,
updatedPersonalData.newPassword,
);
workflowPage.getters.successToast().should('contain', 'Password updated');
successToast().should('contain', 'Password updated');
personalSettingsPage.actions.loginWithNewData(
INSTANCE_OWNER.email,
updatedPersonalData.newPassword,
@ -248,7 +245,7 @@ describe('User Management', { disableAutoLogin: true }, () => {
updatedPersonalData.newPassword,
);
personalSettingsPage.actions.updateEmail(updatedPersonalData.newEmail);
workflowPage.getters.successToast().should('contain', 'Personal details updated');
successToast().should('contain', 'Personal details updated');
personalSettingsPage.actions.loginWithNewData(
updatedPersonalData.newEmail,
updatedPersonalData.newPassword,

View file

@ -1,6 +1,7 @@
import { v4 as uuid } from 'uuid';
import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../constants';
import { errorToast, successToast } from '../pages/notifications';
const workflowPage = new WorkflowPageClass();
const executionsTab = new WorkflowExecutionsTab();
@ -68,7 +69,7 @@ describe('Execution', () => {
workflowPage.getters.clearExecutionDataButton().should('not.exist');
// Check success toast (works because Cypress waits enough for the element to show after the http request node has finished)
workflowPage.getters.successToast().should('be.visible');
successToast().should('be.visible');
});
it('should test manual workflow stop', () => {
@ -127,7 +128,7 @@ describe('Execution', () => {
workflowPage.getters.clearExecutionDataButton().should('not.exist');
// Check success toast (works because Cypress waits enough for the element to show after the http request node has finished)
workflowPage.getters.successToast().should('be.visible');
successToast().should('be.visible');
});
it('should test webhook workflow', () => {
@ -200,7 +201,7 @@ describe('Execution', () => {
workflowPage.getters.clearExecutionDataButton().should('not.exist');
// Check success toast (works because Cypress waits enough for the element to show after the http request node has finished)
workflowPage.getters.successToast().should('be.visible');
successToast().should('be.visible');
});
it('should test webhook workflow stop', () => {
@ -274,7 +275,7 @@ describe('Execution', () => {
workflowPage.getters.clearExecutionDataButton().should('not.exist');
// Check success toast (works because Cypress waits enough for the element to show after the http request node has finished)
workflowPage.getters.successToast().should('be.visible');
successToast().should('be.visible');
});
describe('execution preview', () => {
@ -286,7 +287,7 @@ describe('Execution', () => {
executionsTab.actions.deleteExecutionInPreview();
executionsTab.getters.successfulExecutionListItems().should('have.length', 0);
workflowPage.getters.successToast().contains('Execution deleted');
successToast().contains('Execution deleted');
});
});
@ -587,7 +588,7 @@ describe('Execution', () => {
cy.wait('@workflowRun');
// Wait again for the websocket message to arrive and the UI to update.
cy.wait(100);
workflowPage.getters.errorToast({ timeout: 1 }).should('not.exist');
errorToast({ timeout: 1 }).should('not.exist');
});
it('should execute workflow partially up to the node that has issues', () => {
@ -614,6 +615,6 @@ describe('Execution', () => {
.within(() => cy.get('.fa-check'))
.should('exist');
workflowPage.getters.errorToast().should('contain', 'Problem in node Telegram');
errorToast().should('contain', 'Problem in node Telegram');
});
});

View file

@ -12,6 +12,7 @@ import {
TRELLO_NODE_NAME,
} from '../constants';
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
import { successToast } from '../pages/notifications';
import { getVisibleSelect } from '../utils';
const credentialsPage = new CredentialsPage();
@ -153,7 +154,7 @@ describe('Credentials', () => {
credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.deleteButton().click();
cy.get('.el-message-box').find('button').contains('Yes').click();
workflowPage.getters.successToast().contains('Credential deleted');
successToast().contains('Credential deleted');
workflowPage.getters
.nodeCredentialsSelect()
.find('input')

View file

@ -1,4 +1,5 @@
import { NDV, WorkflowPage } from '../pages';
import { clearNotifications } from '../pages/notifications';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
@ -12,10 +13,7 @@ describe('ADO-2230 NDV Pagination Reset', () => {
// execute node outputting 10 pages, check output of first page
ndv.actions.execute();
workflowPage.getters
.successToast()
.find('.el-notification__closeBtn')
.click({ multiple: true });
clearNotifications();
ndv.getters.outputTbodyCell(1, 1).invoke('text').should('eq', 'Terry.Dach@hotmail.com');
// open 4th page, check output
@ -27,10 +25,7 @@ describe('ADO-2230 NDV Pagination Reset', () => {
// output a lot less data
ndv.getters.parameterInput('randomDataCount').find('input').clear().type('20');
ndv.actions.execute();
workflowPage.getters
.successToast()
.find('.el-notification__closeBtn')
.click({ multiple: true });
clearNotifications();
// check we are back to second page now
ndv.getters.pagination().find('li.number').should('have.length', 2);

View file

@ -1,6 +1,4 @@
import { WorkflowPage } from '../pages';
const workflowPage = new WorkflowPage();
import { errorToast, successToast } from '../pages/notifications';
const INVALID_NAMES = [
'https://n8n.io',
@ -33,8 +31,8 @@ describe('Personal Settings', () => {
cy.getByTestId('personal-data-form').find('input[name="firstName"]').clear().type(name[0]);
cy.getByTestId('personal-data-form').find('input[name="lastName"]').clear().type(name[1]);
cy.getByTestId('save-settings-button').click();
workflowPage.getters.successToast().should('contain', 'Personal details updated');
workflowPage.getters.successToast().find('.el-notification__closeBtn').click();
successToast().should('contain', 'Personal details updated');
successToast().find('.el-notification__closeBtn').click();
});
});
it('not allow malicious values for personal data', () => {
@ -43,10 +41,8 @@ describe('Personal Settings', () => {
cy.getByTestId('personal-data-form').find('input[name="firstName"]').clear().type(name);
cy.getByTestId('personal-data-form').find('input[name="lastName"]').clear().type(name);
cy.getByTestId('save-settings-button').click();
workflowPage.getters
.errorToast()
.should('contain', 'Malicious firstName | Malicious lastName');
workflowPage.getters.errorToast().find('.el-notification__closeBtn').click();
errorToast().should('contain', 'Malicious firstName | Malicious lastName');
errorToast().find('.el-notification__closeBtn').click();
});
});
});

View file

@ -1,5 +1,6 @@
import { WorkflowPage } from '../pages';
import { MessageBox as MessageBoxClass } from '../pages/modals/message-box';
import { errorToast, successToast } from '../pages/notifications';
const workflowPage = new WorkflowPage();
const messageBox = new MessageBoxClass();
@ -29,9 +30,9 @@ describe('Import workflow', () => {
workflowPage.getters.canvasNodes().should('have.length', 4);
workflowPage.getters.errorToast().should('not.exist');
errorToast().should('not.exist');
workflowPage.getters.successToast().should('not.exist');
successToast().should('not.exist');
});
it('clicking outside modal should not show error toast', () => {
@ -42,7 +43,7 @@ describe('Import workflow', () => {
cy.get('body').click(0, 0);
workflowPage.getters.errorToast().should('not.exist');
errorToast().should('not.exist');
});
it('canceling modal should not show error toast', () => {
@ -52,7 +53,7 @@ describe('Import workflow', () => {
workflowPage.getters.workflowMenuItemImportFromURLItem().click();
messageBox.getters.cancel().click();
workflowPage.getters.errorToast().should('not.exist');
errorToast().should('not.exist');
});
});

View file

@ -0,0 +1,143 @@
import { INSTANCE_ADMIN } from '../constants';
import { clearNotifications } from '../pages/notifications';
import {
getNpsSurvey,
getNpsSurveyClose,
getNpsSurveyEmail,
getNpsSurveyRatings,
} from '../pages/npsSurvey';
import { WorkflowPage } from '../pages/workflow';
const workflowPage = new WorkflowPage();
const NOW = 1717771477012;
const ONE_DAY = 24 * 60 * 60 * 1000;
const THREE_DAYS = ONE_DAY * 3;
const SEVEN_DAYS = ONE_DAY * 7;
const ABOUT_SIX_MONTHS = ONE_DAY * 30 * 6 + ONE_DAY;
describe('NpsSurvey', () => {
beforeEach(() => {
cy.resetDatabase();
cy.signin(INSTANCE_ADMIN);
});
it('shows nps survey to recently activated user and can submit email ', () => {
cy.intercept('/rest/settings', { middleware: true }, (req) => {
req.on('response', (res) => {
if (res.body.data) {
res.body.data.telemetry = {
enabled: true,
config: {
key: 'test',
url: 'https://telemetry-test.n8n.io',
},
};
}
});
});
cy.intercept('/rest/login', { middleware: true }, (req) => {
req.on('response', (res) => {
if (res.body.data) {
res.body.data.settings = res.body.data.settings || {};
res.body.data.settings.userActivated = true;
res.body.data.settings.userActivatedAt = NOW - THREE_DAYS - 1000;
}
});
});
workflowPage.actions.visit(true, NOW);
workflowPage.actions.saveWorkflowOnButtonClick();
getNpsSurvey().should('be.visible');
getNpsSurveyRatings().find('button').should('have.length', 11);
getNpsSurveyRatings().find('button').first().click();
getNpsSurveyEmail().find('input').type('test@n8n.io');
getNpsSurveyEmail().find('button').click();
// test that modal does not show up again until 6 months later
workflowPage.actions.visit(true, NOW + ONE_DAY);
workflowPage.actions.saveWorkflowOnButtonClick();
getNpsSurvey().should('not.be.visible');
// 6 months later
workflowPage.actions.visit(true, NOW + ABOUT_SIX_MONTHS);
workflowPage.actions.saveWorkflowOnButtonClick();
getNpsSurvey().should('be.visible');
});
it('allows user to ignore survey 3 times before stopping to show until 6 months later', () => {
cy.intercept('/rest/settings', { middleware: true }, (req) => {
req.on('response', (res) => {
if (res.body.data) {
res.body.data.telemetry = {
enabled: true,
config: {
key: 'test',
url: 'https://telemetry-test.n8n.io',
},
};
}
});
});
cy.intercept('/rest/login', { middleware: true }, (req) => {
req.on('response', (res) => {
if (res.body.data) {
res.body.data.settings = res.body.data.settings || {};
res.body.data.settings.userActivated = true;
res.body.data.settings.userActivatedAt = NOW - THREE_DAYS - 1000;
}
});
});
// can ignore survey and it won't show up again
workflowPage.actions.visit(true, NOW);
workflowPage.actions.saveWorkflowOnButtonClick();
clearNotifications();
getNpsSurvey().should('be.visible');
getNpsSurveyClose().click();
getNpsSurvey().should('not.be.visible');
workflowPage.actions.visit(true, NOW + ONE_DAY);
workflowPage.actions.saveWorkflowOnButtonClick();
getNpsSurvey().should('not.be.visible');
// shows up seven days later to ignore again
workflowPage.actions.visit(true, NOW + SEVEN_DAYS + 10000);
workflowPage.actions.saveWorkflowOnButtonClick();
clearNotifications();
getNpsSurvey().should('be.visible');
getNpsSurveyClose().click();
getNpsSurvey().should('not.be.visible');
workflowPage.actions.visit(true, NOW + SEVEN_DAYS + 10000);
workflowPage.actions.saveWorkflowOnButtonClick();
getNpsSurvey().should('not.be.visible');
// shows up after at least seven days later to ignore again
workflowPage.actions.visit(true, NOW + (SEVEN_DAYS + 10000) * 2 + ONE_DAY);
workflowPage.actions.saveWorkflowOnButtonClick();
clearNotifications();
getNpsSurvey().should('be.visible');
getNpsSurveyClose().click();
getNpsSurvey().should('not.be.visible');
workflowPage.actions.visit(true, NOW + (SEVEN_DAYS + 10000) * 2 + ONE_DAY * 2);
workflowPage.actions.saveWorkflowOnButtonClick();
getNpsSurvey().should('not.be.visible');
// does not show up again after at least 7 days
workflowPage.actions.visit(true, NOW + (SEVEN_DAYS + 10000) * 3 + ONE_DAY * 3);
workflowPage.actions.saveWorkflowOnButtonClick();
getNpsSurvey().should('not.be.visible');
// shows up 6 months later
workflowPage.actions.visit(true, NOW + (SEVEN_DAYS + 10000) * 3 + ABOUT_SIX_MONTHS);
workflowPage.actions.saveWorkflowOnButtonClick();
getNpsSurvey().should('be.visible');
});
});

View file

@ -5,6 +5,7 @@ import { NDV, WorkflowPage } from '../pages';
import { NodeCreator } from '../pages/features/node-creator';
import { clickCreateNewCredential } from '../composables/ndv';
import { setCredentialValues } from '../composables/modals/credential-modal';
import { successToast } from '../pages/notifications';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
@ -734,7 +735,7 @@ describe('NDV', () => {
ndv.getters.triggerPanelExecuteButton().realClick();
cy.wait('@workflowRun').then(() => {
ndv.getters.triggerPanelExecuteButton().should('contain', 'Test step');
workflowPage.getters.successToast().should('exist');
successToast().should('exist');
});
});

View file

@ -1,5 +1,6 @@
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { NDV } from '../pages/ndv';
import { successToast } from '../pages/notifications';
const WorkflowPage = new WorkflowPageClass();
const ndv = new NDV();
@ -28,13 +29,13 @@ describe('Code node', () => {
it('should execute the placeholder successfully in both modes', () => {
ndv.actions.execute();
WorkflowPage.getters.successToast().contains('Node executed successfully');
successToast().contains('Node executed successfully');
ndv.getters.parameterInput('mode').click();
ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item');
ndv.actions.execute();
WorkflowPage.getters.successToast().contains('Node executed successfully');
successToast().contains('Node executed successfully');
});
});

View file

@ -12,6 +12,7 @@ import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows';
import { getVisibleSelect } from '../utils';
import { WorkflowExecutionsTab } from '../pages';
import { errorToast, successToast } from '../pages/notifications';
const NEW_WORKFLOW_NAME = 'Something else';
const DUPLICATE_WORKFLOW_NAME = 'Duplicated workflow';
@ -36,7 +37,7 @@ describe('Workflow Actions', () => {
WorkflowPage.getters.isWorkflowSaved();
});
it('should not save already saved workflow', () => {
it.skip('should not save already saved workflow', () => {
cy.intercept('PATCH', '/rest/workflows/*').as('saveWorkflow');
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
@ -72,19 +73,19 @@ describe('Workflow Actions', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.successToast().should('exist');
successToast().should('exist');
WorkflowPage.actions.clickWorkflowActivator();
WorkflowPage.getters.errorToast().should('exist');
errorToast().should('exist');
});
it('should be be able to activate workflow when nodes with errors are disabled', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.successToast().should('exist');
successToast().should('exist');
// First, try to activate the workflow with errors
WorkflowPage.actions.clickWorkflowActivator();
WorkflowPage.getters.errorToast().should('exist');
errorToast().should('exist');
// Now, disable the node with errors
WorkflowPage.getters.canvasNodes().last().click();
WorkflowPage.actions.hitDisableNodeShortcut();
@ -174,7 +175,7 @@ describe('Workflow Actions', () => {
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('a');
cy.get('.jtk-drag-selected').should('have.length', 2);
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('c');
WorkflowPage.getters.successToast().should('exist');
successToast().should('exist');
});
it('should paste nodes (both current and old node versions)', () => {
@ -239,7 +240,7 @@ describe('Workflow Actions', () => {
// Save settings
WorkflowPage.getters.workflowSettingsSaveButton().click();
WorkflowPage.getters.workflowSettingsModal().should('not.exist');
WorkflowPage.getters.successToast().should('exist');
successToast().should('exist');
});
}).as('loadWorkflows');
});
@ -257,7 +258,7 @@ describe('Workflow Actions', () => {
WorkflowPage.getters.workflowMenuItemDelete().click();
cy.get('div[role=dialog][aria-modal=true]').should('be.visible');
cy.get('button.btn--confirm').should('be.visible').click();
WorkflowPage.getters.successToast().should('exist');
successToast().should('exist');
cy.url().should('include', WorkflowPages.url);
});
@ -286,7 +287,7 @@ describe('Workflow Actions', () => {
.contains('Duplicate')
.should('be.visible');
WorkflowPage.getters.duplicateWorkflowModal().find('button').contains('Duplicate').click();
WorkflowPage.getters.errorToast().should('not.exist');
errorToast().should('not.exist');
}
beforeEach(() => {
@ -331,14 +332,14 @@ describe('Workflow Actions', () => {
WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.executeWorkflowButton().click();
WorkflowPage.getters.successToast().should('contain.text', 'Workflow executed successfully');
successToast().should('contain.text', 'Workflow executed successfully');
});
it('should run workflow using keyboard shortcut', () => {
WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('{enter}');
WorkflowPage.getters.successToast().should('contain.text', 'Workflow executed successfully');
successToast().should('contain.text', 'Workflow executed successfully');
});
it('should not run empty workflows', () => {
@ -350,7 +351,7 @@ describe('Workflow Actions', () => {
WorkflowPage.getters.executeWorkflowButton().should('be.disabled');
// Keyboard shortcut should not work
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('{enter}');
WorkflowPage.getters.successToast().should('not.exist');
successToast().should('not.exist');
});
});

View file

@ -18,6 +18,7 @@
},
"dependencies": {
"@ngneat/falso": "^6.4.0",
"@sinonjs/fake-timers": "^11.2.2",
"cross-env": "^7.0.3",
"cypress": "^13.6.2",
"cypress-otp": "^1.0.3",

View file

@ -0,0 +1,17 @@
type CyGetOptions = Parameters<(typeof cy)['get']>[1];
/**
* Getters
*/
export const successToast = () => cy.get('.el-notification:has(.el-notification--success)');
export const warningToast = () => cy.get('.el-notification:has(.el-notification--warning)');
export const errorToast = (options?: CyGetOptions) =>
cy.get('.el-notification:has(.el-notification--error)', options);
export const infoToast = () => cy.get('.el-notification:has(.el-notification--info)');
/**
* Actions
*/
export const clearNotifications = () => {
successToast().find('.el-notification__closeBtn').click({ multiple: true });
};

View file

@ -0,0 +1,16 @@
/**
* Getters
*/
export const getNpsSurvey = () => cy.getByTestId('nps-survey-modal');
export const getNpsSurveyRatings = () => cy.getByTestId('nps-survey-ratings');
export const getNpsSurveyEmail = () => cy.getByTestId('nps-survey-email');
export const getNpsSurveyClose = () =>
cy.getByTestId('nps-survey-modal').find('button.el-drawer__close-btn');
/**
* Actions
*/

View file

@ -3,8 +3,6 @@ import { getVisibleSelect } from '../utils';
import { BasePage } from './base';
import { NodeCreator } from './features/node-creator';
type CyGetOptions = Parameters<(typeof cy)['get']>[1];
const nodeCreator = new NodeCreator();
export class WorkflowPage extends BasePage {
url = '/workflow/new';
@ -49,11 +47,6 @@ export class WorkflowPage extends BasePage {
canvasNodePlusEndpointByName: (nodeName: string, index = 0) => {
return cy.get(this.getters.getEndpointSelector('plus', nodeName, index));
},
successToast: () => cy.get('.el-notification:has(.el-notification--success)'),
warningToast: () => cy.get('.el-notification:has(.el-notification--warning)'),
errorToast: (options?: CyGetOptions) =>
cy.get('.el-notification:has(.el-notification--error)', options),
infoToast: () => cy.get('.el-notification:has(.el-notification--info)'),
activatorSwitch: () => cy.getByTestId('workflow-activate-switch'),
workflowMenu: () => cy.getByTestId('workflow-menu'),
firstStepButton: () => cy.getByTestId('canvas-add-button'),
@ -137,8 +130,11 @@ export class WorkflowPage extends BasePage {
};
actions = {
visit: (preventNodeViewUnload = true) => {
visit: (preventNodeViewUnload = true, appDate?: number) => {
cy.visit(this.url);
if (appDate) {
cy.setAppDate(appDate);
}
cy.waitForLoad();
cy.window().then((win) => {
win.preventNodeViewBeforeUnload = preventNodeViewUnload;

View file

@ -1,4 +1,5 @@
import 'cypress-real-events';
import FakeTimers from '@sinonjs/fake-timers';
import { WorkflowPage } from '../pages';
import {
BACKEND_BASE_URL,
@ -8,6 +9,16 @@ import {
N8N_AUTH_COOKIE,
} from '../constants';
Cypress.Commands.add('setAppDate', (targetDate: number | Date) => {
cy.window().then((win) => {
FakeTimers.withGlobal(win).install({
now: targetDate,
toFake: ['Date'],
shouldAdvanceTime: true,
});
});
});
Cypress.Commands.add('getByTestId', (selector, ...args) => {
return cy.get(`[data-test-id="${selector}"]`, ...args);
});

View file

@ -54,6 +54,7 @@ declare global {
}
>;
resetDatabase(): void;
setAppDate(targetDate: number | Date): void;
}
}
}

View file

@ -71,6 +71,7 @@ import { InvitationController } from './controllers/invitation.controller';
import { OrchestrationService } from '@/services/orchestration.service';
import { ProjectController } from './controllers/project.controller';
import { RoleController } from './controllers/role.controller';
import { UserSettingsController } from './controllers/userSettings.controller';
const exec = promisify(callbackExec);
@ -148,6 +149,7 @@ export class Server extends AbstractServer {
ProjectController,
RoleController,
CurlController,
UserSettingsController,
];
if (

View file

@ -0,0 +1,52 @@
import { Patch, RestController } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NpsSurveyRequest } from '@/requests';
import { UserService } from '@/services/user.service';
import type { NpsSurveyState } from 'n8n-workflow';
function getNpsSurveyState(state: unknown): NpsSurveyState | undefined {
if (typeof state !== 'object' || state === null) {
return;
}
if (!('lastShownAt' in state) || typeof state.lastShownAt !== 'number') {
return;
}
if ('responded' in state && state.responded === true) {
return {
responded: true,
lastShownAt: state.lastShownAt,
};
}
if (
'waitingForResponse' in state &&
state.waitingForResponse === true &&
'ignoredCount' in state &&
typeof state.ignoredCount === 'number'
) {
return {
waitingForResponse: true,
ignoredCount: state.ignoredCount,
lastShownAt: state.lastShownAt,
};
}
return;
}
@RestController('/user-settings')
export class UserSettingsController {
constructor(private readonly userService: UserService) {}
@Patch('/nps-survey')
async updateNpsSurvey(req: NpsSurveyRequest.NpsSurveyUpdate): Promise<void> {
const state = getNpsSurveyState(req.body);
if (!state) {
throw new BadRequestError('Invalid nps survey state structure');
}
await this.userService.updateSettings(req.user.id, {
npsSurvey: state,
});
}
}

View file

@ -0,0 +1,20 @@
import type { MigrationContext, ReversibleMigration } from '@db/types';
export class AddActivatedAtUserSetting1717498465931 implements ReversibleMigration {
async up({ queryRunner, escape }: MigrationContext) {
const now = Date.now();
await queryRunner.query(
`UPDATE ${escape.tableName('user')}
SET settings = JSON_SET(COALESCE(settings, '{}'), '$.userActivatedAt', CAST('${now}' AS JSON))
WHERE settings IS NOT NULL AND JSON_EXTRACT(settings, '$.userActivated') = true`,
);
}
async down({ queryRunner, escape }: MigrationContext) {
await queryRunner.query(
`UPDATE ${escape.tableName('user')}
SET settings = JSON_REMOVE(CAST(settings AS JSON), '$.userActivatedAt')
WHERE settings IS NOT NULL`,
);
}
}

View file

@ -57,6 +57,7 @@ import { RemoveFailedExecutionStatus1711018413374 } from '../common/171101841337
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess';
import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable';
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238,
@ -117,4 +118,5 @@ export const mysqlMigrations: Migration[] = [
RemoveNodesAccess1712044305787,
CreateProject1714133768519,
MakeExecutionStatusNonNullable1714133768521,
AddActivatedAtUserSetting1717498465931,
];

View file

@ -0,0 +1,18 @@
import type { MigrationContext, ReversibleMigration } from '@db/types';
export class AddActivatedAtUserSetting1717498465931 implements ReversibleMigration {
async up({ queryRunner, escape }: MigrationContext) {
const now = Date.now();
await queryRunner.query(
`UPDATE ${escape.tableName('user')}
SET settings = jsonb_set(COALESCE(settings::jsonb, '{}'), '{userActivatedAt}', to_jsonb(${now}))
WHERE settings IS NOT NULL AND (settings->>'userActivated')::boolean = true`,
);
}
async down({ queryRunner, escape }: MigrationContext) {
await queryRunner.query(
`UPDATE ${escape.tableName('user')} SET settings = settings::jsonb - 'userActivatedAt' WHERE settings IS NOT NULL`,
);
}
}

View file

@ -56,6 +56,7 @@ import { RemoveFailedExecutionStatus1711018413374 } from '../common/171101841337
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess';
import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable';
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
export const postgresMigrations: Migration[] = [
InitialMigration1587669153312,
@ -115,4 +116,5 @@ export const postgresMigrations: Migration[] = [
RemoveNodesAccess1712044305787,
CreateProject1714133768519,
MakeExecutionStatusNonNullable1714133768521,
AddActivatedAtUserSetting1717498465931,
];

View file

@ -0,0 +1,20 @@
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
export class AddActivatedAtUserSetting1717498465931 implements ReversibleMigration {
transaction = false as const;
async up({ queryRunner, escape }: MigrationContext) {
const now = Date.now();
await queryRunner.query(
`UPDATE ${escape.tableName('user')}
SET settings = JSON_SET(settings, '$.userActivatedAt', ${now})
WHERE JSON_EXTRACT(settings, '$.userActivated') = true;`,
);
}
async down({ queryRunner, escape }: MigrationContext) {
await queryRunner.query(
`UPDATE ${escape.tableName('user')} SET settings = JSON_REMOVE(settings, '$.userActivatedAt')`,
);
}
}

View file

@ -54,6 +54,7 @@ import { RemoveFailedExecutionStatus1711018413374 } from '../common/171101841337
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess';
import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable';
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@ -111,6 +112,7 @@ const sqliteMigrations: Migration[] = [
RemoveNodesAccess1712044305787,
CreateProject1714133768519,
MakeExecutionStatusNonNullable1714133768521,
AddActivatedAtUserSetting1717498465931,
];
export { sqliteMigrations };

View file

@ -601,3 +601,13 @@ export declare namespace ProjectRequest {
>;
type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>;
}
// ----------------------------------
// /nps-survey
// ----------------------------------
export declare namespace NpsSurveyRequest {
// can be refactored to
// type NpsSurveyUpdate = AuthenticatedRequest<{}, {}, NpsSurveyState>;
// once some schema validation is added
type NpsSurveyUpdate = AuthenticatedRequest<{}, {}, unknown>;
}

View file

@ -63,6 +63,7 @@ export class EventsService extends EventEmitter {
await Container.get(UserService).updateSettings(owner.id, {
firstSuccessfulWorkflowId: workflowId,
userActivated: true,
userActivatedAt: runData.startedAt.getTime(),
});
}

View file

@ -0,0 +1,148 @@
import { UserSettingsController } from '@/controllers/userSettings.controller';
import type { NpsSurveyRequest } from '@/requests';
import type { UserService } from '@/services/user.service';
import { mock } from 'jest-mock-extended';
import type { NpsSurveyState } from 'n8n-workflow';
const NOW = 1717607016208;
jest.useFakeTimers({
now: NOW,
});
describe('UserSettingsController', () => {
const userService = mock<UserService>();
const controller = new UserSettingsController(userService);
describe('NPS Survey', () => {
test.each([
[
'updates user settings, setting response state to done',
{
responded: true,
lastShownAt: 1717607016208,
},
[],
],
[
'updates user settings, setting response state to done, ignoring other keys like waitForResponse',
{
responded: true,
lastShownAt: 1717607016208,
waitingForResponse: true,
},
['waitingForResponse'],
],
[
'updates user settings, setting response state to done, ignoring other keys like ignoredCount',
{
responded: true,
lastShownAt: 1717607016208,
ignoredCount: 1,
},
['ignoredCount'],
],
[
'updates user settings, setting response state to done, ignoring other unknown keys',
{
responded: true,
lastShownAt: 1717607016208,
x: 1,
},
['x'],
],
[
'updates user settings, updating ignore count',
{
waitingForResponse: true,
lastShownAt: 1717607016208,
ignoredCount: 1,
},
[],
],
[
'updates user settings, reseting to waiting state',
{
waitingForResponse: true,
ignoredCount: 0,
lastShownAt: 1717607016208,
},
[],
],
[
'updates user settings, updating ignore count, ignoring unknown keys',
{
waitingForResponse: true,
lastShownAt: 1717607016208,
ignoredCount: 1,
x: 1,
},
['x'],
],
])('%s', async (_, toUpdate, toIgnore: string[] | undefined) => {
const req = mock<NpsSurveyRequest.NpsSurveyUpdate>();
req.user.id = '1';
req.body = toUpdate;
await controller.updateNpsSurvey(req);
const npsSurvey = Object.keys(toUpdate).reduce(
(accu, key) => {
if ((toIgnore ?? []).includes(key)) {
return accu;
}
accu[key] = (toUpdate as Record<string, unknown>)[key];
return accu;
},
{} as Record<string, unknown>,
);
expect(userService.updateSettings).toHaveBeenCalledWith('1', { npsSurvey });
});
it('updates user settings, setting response state to done', async () => {
const req = mock<NpsSurveyRequest.NpsSurveyUpdate>();
req.user.id = '1';
const npsSurvey: NpsSurveyState = {
responded: true,
lastShownAt: 1717607016208,
};
req.body = npsSurvey;
await controller.updateNpsSurvey(req);
expect(userService.updateSettings).toHaveBeenCalledWith('1', { npsSurvey });
});
it('updates user settings, updating ignore count', async () => {
const req = mock<NpsSurveyRequest.NpsSurveyUpdate>();
req.user.id = '1';
const npsSurvey: NpsSurveyState = {
waitingForResponse: true,
lastShownAt: 1717607016208,
ignoredCount: 1,
};
req.body = npsSurvey;
await controller.updateNpsSurvey(req);
expect(userService.updateSettings).toHaveBeenCalledWith('1', { npsSurvey });
});
test.each([
['is missing', {}],
['is undefined', undefined],
['is responded but missing lastShownAt', { responded: true }],
['is waitingForResponse but missing lastShownAt', { waitingForResponse: true }],
[
'is waitingForResponse but missing ignoredCount',
{ lastShownAt: 123, waitingForResponse: true },
],
])('thows error when request payload is %s', async (_, payload) => {
const req = mock<NpsSurveyRequest.NpsSurveyUpdate>();
req.user.id = '1';
req.body = payload;
await expect(controller.updateNpsSurvey(req)).rejects.toThrowError();
});
});
});

View file

@ -217,9 +217,9 @@
// Avatar
--color-avatar-font: var(--prim-gray-0);
// Value Survey
--color-value-survey-background: var(--prim-gray-740);
--color-value-survey-font: var(--prim-gray-0);
// NPS Survey
--color-nps-survey-background: var(--prim-gray-740);
--color-nps-survey-font: var(--prim-gray-0);
// Switch (Activation, boolean)
--color-switch-background: var(--prim-gray-820);

View file

@ -278,9 +278,9 @@
// Avatar
--color-avatar-font: var(--color-text-xlight);
// Value Survey
--color-value-survey-background: var(--prim-gray-740);
--color-value-survey-font: var(--prim-gray-0);
// NPS Survey
--color-nps-survey-background: var(--prim-gray-740);
--color-nps-survey-font: var(--prim-gray-0);
// Action Dropdown
--color-action-dropdown-item-active-background: var(--color-background-base);

View file

@ -742,18 +742,9 @@ export interface IUserListAction {
}
export interface IN8nPrompts {
message: string;
title: string;
showContactPrompt: boolean;
showValueSurvey: boolean;
}
export interface IN8nValueSurveyData {
[key: string]: string;
}
export interface IN8nPromptResponse {
updated: boolean;
message?: string;
title?: string;
showContactPrompt?: boolean;
}
export const enum UserManagementAuthenticationMethod {
@ -1214,6 +1205,8 @@ export type Modals = {
[key: string]: ModalState;
};
export type ModalKey = keyof Modals;
export type ModalState = {
open: boolean;
mode?: string | null;
@ -1366,7 +1359,6 @@ export interface INodeCreatorState {
export interface ISettingsState {
initialized: boolean;
settings: IN8nUISettings;
promptsData: IN8nPrompts;
userManagement: IUserManagementSettings;
templatesEndpointHealthy: boolean;
api: {
@ -1931,3 +1923,7 @@ export type EnterpriseEditionFeatureKey =
| 'AdvancedPermissions';
export type EnterpriseEditionFeatureValue = keyof Omit<IN8nUISettings['enterprise'], 'projects'>;
export interface IN8nPromptResponse {
updated: boolean;
}

View file

@ -39,12 +39,6 @@ export const waitAllPromises = async () => await new Promise((resolve) => setTim
export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
initialized: true,
settings: defaultSettings,
promptsData: {
message: '',
title: '',
showContactPrompt: false,
showValueSurvey: false,
},
userManagement: {
showSetupOnFirstLoad: false,
smtpSetup: false,

View file

@ -0,0 +1,7 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { NpsSurveyState } from 'n8n-workflow';
export async function updateNpsSurveyState(context: IRestApiContext, state: NpsSurveyState) {
await makeRestApiRequest(context, 'PATCH', '/user-settings/nps-survey', state);
}

View file

@ -1,9 +1,4 @@
import type {
IRestApiContext,
IN8nPrompts,
IN8nValueSurveyData,
IN8nPromptResponse,
} from '../Interface';
import type { IRestApiContext, IN8nPrompts, IN8nPromptResponse } from '../Interface';
import { makeRestApiRequest, get, post } from '@/utils/apiUtils';
import { N8N_IO_BASE_URL, NPM_COMMUNITY_NODE_SEARCH_API_URL } from '@/constants';
import type { IN8nUISettings } from 'n8n-workflow';
@ -34,17 +29,6 @@ export async function submitContactInfo(
);
}
export async function submitValueSurvey(
instanceId: string,
userId: string,
params: IN8nValueSurveyData,
): Promise<IN8nPromptResponse> {
return await post(N8N_IO_BASE_URL, '/value-survey', params, {
'n8n-instance-id': instanceId,
'n8n-user-id': userId,
});
}
export async function getAvailableCommunityPackageCount(): Promise<number> {
const response = await get(
NPM_COMMUNITY_NODE_SEARCH_API_URL,

View file

@ -33,20 +33,27 @@
</template>
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import type { IN8nPromptResponse } from '@/Interface';
import type { IN8nPromptResponse, ModalKey } from '@/Interface';
import { VALID_EMAIL_REGEX } from '@/constants';
import Modal from '@/components/Modal.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { createEventBus } from 'n8n-design-system/utils';
import { useToast } from '@/composables/useToast';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
export default defineComponent({
name: 'ContactPromptModal',
components: { Modal },
props: ['modalName'],
props: {
modalName: {
type: String as PropType<ModalKey>,
required: true,
},
},
setup() {
return {
...useToast(),
@ -59,17 +66,17 @@ export default defineComponent({
};
},
computed: {
...mapStores(useRootStore, useSettingsStore),
...mapStores(useRootStore, useSettingsStore, useNpsSurveyStore),
title(): string {
if (this.settingsStore.promptsData && this.settingsStore.promptsData.title) {
return this.settingsStore.promptsData.title;
if (this.npsSurveyStore.promptsData?.title) {
return this.npsSurveyStore.promptsData.title;
}
return 'Youre a power user 💪';
},
description(): string {
if (this.settingsStore.promptsData && this.settingsStore.promptsData.message) {
return this.settingsStore.promptsData.message;
if (this.npsSurveyStore.promptsData?.message) {
return this.npsSurveyStore.promptsData.message;
}
return 'Your experience with n8n can help us improve — for you and our entire community.';

View file

@ -55,6 +55,7 @@ import type {
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import type { BaseTextKey } from '../../plugins/i18n';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
const props = defineProps<{
workflow: IWorkflowDb;
@ -72,6 +73,7 @@ const uiStore = useUIStore();
const usersStore = useUsersStore();
const workflowsStore = useWorkflowsStore();
const projectsStore = useProjectsStore();
const npsSurveyStore = useNpsSurveyStore();
const router = useRouter();
const route = useRoute();
@ -250,7 +252,7 @@ async function onSaveButtonClick() {
if (saved) {
showCreateWorkflowSuccessToast(id);
await settingsStore.fetchPromptsData();
await npsSurveyStore.fetchPromptsData();
if (route.name === VIEWS.EXECUTION_DEBUG) {
await router.replace({

View file

@ -54,13 +54,14 @@ import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import type { EventBus } from 'n8n-design-system';
import { useUIStore } from '@/stores/ui.store';
import type { ModalKey } from '@/Interface';
export default defineComponent({
name: 'Modal',
props: {
...ElDialog.props,
name: {
type: String,
type: String as PropType<ModalKey>,
},
title: {
type: String,

View file

@ -12,15 +12,17 @@
</template>
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { useUIStore } from '@/stores/ui.store';
import { mapStores } from 'pinia';
import type { ModalKey } from '@/Interface';
export default defineComponent({
name: 'ModalRoot',
props: {
name: {
type: String,
type: String as PropType<ModalKey>,
required: true,
},
keepAlive: {

View file

@ -41,9 +41,9 @@
<UpdatesPanel />
</ModalRoot>
<ModalRoot :name="VALUE_SURVEY_MODAL_KEY" :keep-alive="true">
<ModalRoot :name="NPS_SURVEY_MODAL_KEY" :keep-alive="true">
<template #default="{ active }">
<ValueSurvey :is-active="active" />
<NpsSurvey :is-active="active" />
</template>
</ModalRoot>
@ -187,7 +187,7 @@ import {
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
VALUE_SURVEY_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
VERSIONS_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_LM_CHAT_MODAL_KEY,
@ -220,7 +220,7 @@ import OnboardingCallSignupModal from './OnboardingCallSignupModal.vue';
import PersonalizationModal from './PersonalizationModal.vue';
import TagsManager from './TagsManager/TagsManager.vue';
import UpdatesPanel from './UpdatesPanel.vue';
import ValueSurvey from './ValueSurvey.vue';
import NpsSurvey from './NpsSurvey.vue';
import WorkflowLMChat from './WorkflowLMChat.vue';
import WorkflowSettings from './WorkflowSettings.vue';
import DeleteUserModal from './DeleteUserModal.vue';
@ -257,7 +257,7 @@ export default defineComponent({
PersonalizationModal,
TagsManager,
UpdatesPanel,
ValueSurvey,
NpsSurvey,
WorkflowLMChat,
WorkflowSettings,
WorkflowShareModal,
@ -291,7 +291,7 @@ export default defineComponent({
WORKFLOW_LM_CHAT_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
VALUE_SURVEY_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
IMPORT_CURL_MODAL_KEY,
GENERATE_CURL_MODAL_KEY,

View file

@ -0,0 +1,283 @@
<script lang="ts" setup>
import { VALID_EMAIL_REGEX, NPS_SURVEY_MODAL_KEY } from '@/constants';
import { useRootStore } from '@/stores/n8nRoot.store';
import ModalDrawer from '@/components/ModalDrawer.vue';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n';
import { ref, computed, watch } from 'vue';
import { createEventBus } from 'n8n-design-system/utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
const props = defineProps({
isActive: {
type: Boolean,
},
});
const rootStore = useRootStore();
const i18n = useI18n();
const toast = useToast();
const telemetry = useTelemetry();
const DEFAULT_TITLE = i18n.baseText('prompts.npsSurvey.recommendationQuestion');
const GREAT_FEEDBACK_TITLE = i18n.baseText('prompts.npsSurvey.greatFeedbackTitle');
const DEFAULT_FEEDBACK_TITLE = i18n.baseText('prompts.npsSurvey.defaultFeedbackTitle');
const PRODUCT_TEAM_MESSAGE = i18n.baseText('prompts.productTeamMessage');
const VERY_LIKELY_OPTION = i18n.baseText('prompts.npsSurvey.veryLikely');
const NOT_LIKELY_OPTION = i18n.baseText('prompts.npsSurvey.notLikely');
const SEND = i18n.baseText('prompts.npsSurvey.send');
const YOUR_EMAIL_ADDRESS = i18n.baseText('prompts.npsSurvey.yourEmailAddress');
const form = ref<{ value: string; email: string }>({ value: '', email: '' });
const showButtons = ref(true);
const modalBus = createEventBus();
const modalTitle = computed(() => {
if (form?.value?.value !== '') {
if (Number(form.value) > 7) {
return GREAT_FEEDBACK_TITLE;
} else {
return DEFAULT_FEEDBACK_TITLE;
}
}
return DEFAULT_TITLE;
});
const isEmailValid = computed(
() => form?.value?.email && VALID_EMAIL_REGEX.test(String(form.value.email).toLowerCase()),
);
async function closeDialog(): Promise<void> {
if (form.value.value === '') {
telemetry.track('User responded value survey score', {
instance_id: rootStore.instanceId,
nps: '',
});
await useNpsSurveyStore().ignoreNpsSurvey();
}
if (form.value.value !== '' && form.value.email === '') {
telemetry.track('User responded value survey email', {
instance_id: rootStore.instanceId,
email: '',
nps: form.value.value,
});
}
}
function onInputChange(value: string) {
form.value.email = value;
}
async function selectSurveyValue(value: string) {
form.value.value = value;
showButtons.value = false;
telemetry.track('User responded value survey score', {
instance_id: rootStore.instanceId,
nps: form.value.value,
});
await useNpsSurveyStore().respondNpsSurvey();
}
async function send() {
if (isEmailValid.value) {
telemetry.track('User responded value survey email', {
instance_id: rootStore.instanceId,
email: form.value.email,
nps: form.value.value,
});
toast.showMessage({
title: i18n.baseText('prompts.npsSurvey.thanks'),
message: Number(form.value.value) >= 8 ? i18n.baseText('prompts.npsSurvey.reviewUs') : '',
type: 'success',
duration: 15000,
});
setTimeout(() => {
form.value.value = '';
form.value.email = '';
showButtons.value = true;
}, 1000);
modalBus.emit('close');
}
}
watch(
() => props.isActive,
(isActive) => {
if (isActive) {
telemetry.track('User shown value survey', {
instance_id: rootStore.instanceId,
});
}
},
);
</script>
<template>
<ModalDrawer
:name="NPS_SURVEY_MODAL_KEY"
:event-bus="modalBus"
:before-close="closeDialog"
:modal="false"
:wrapper-closable="false"
direction="btt"
width="120px"
class="nps-survey"
:class="$style.npsSurvey"
data-test-id="nps-survey-modal"
>
<template #header>
<div :class="$style.title">
<n8n-heading tag="h2" size="medium" color="text-xlight">{{ modalTitle }}</n8n-heading>
</div>
</template>
<template #content>
<section :class="$style.content">
<div v-if="showButtons" :class="$style.wrapper">
<div :class="$style.buttons" data-test-id="nps-survey-ratings">
<div v-for="value in 11" :key="value - 1" :class="$style.container">
<n8n-button
type="tertiary"
:label="(value - 1).toString()"
square
@click="selectSurveyValue((value - 1).toString())"
/>
</div>
</div>
<div :class="$style.text">
<n8n-text size="small" color="text-xlight">{{ NOT_LIKELY_OPTION }}</n8n-text>
<n8n-text size="small" color="text-xlight">{{ VERY_LIKELY_OPTION }}</n8n-text>
</div>
</div>
<div v-else :class="$style.email">
<div :class="$style.input" @keyup.enter="send" data-test-id="nps-survey-email">
<n8n-input
v-model="form.email"
:placeholder="YOUR_EMAIL_ADDRESS"
@update:model-value="onInputChange"
/>
<div :class="$style.button">
<n8n-button :label="SEND" float="right" :disabled="!isEmailValid" @click="send" />
</div>
</div>
<div :class="$style.disclaimer">
<n8n-text size="small" color="text-dark">
{{ PRODUCT_TEAM_MESSAGE }}
</n8n-text>
</div>
</div>
</section>
</template>
</ModalDrawer>
</template>
<style module lang="scss">
.title {
height: 16px;
text-align: center;
@media (max-width: $breakpoint-xs) {
margin-top: 10px;
padding: 0 15px;
}
h2 {
color: var(--color-nps-survey-font);
}
}
.content {
display: flex;
justify-content: center;
@media (max-width: $breakpoint-xs) {
margin-top: 20px;
}
}
.wrapper {
display: flex;
flex-direction: column;
.text span {
color: var(--color-nps-survey-font);
}
}
.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);
}
.npsSurvey {
background: var(--color-nps-survey-background);
height: 120px;
top: auto;
@media (max-width: $breakpoint-xs) {
height: 140px;
}
@media (max-width: $breakpoint-xs) {
height: 140px !important;
}
header {
height: 50px;
margin: 0;
padding: 18px 0 16px;
button {
top: 12px;
right: 16px;
position: absolute;
font-weight: var(--font-weight-bold);
color: var(--color-nps-survey-font);
@media (max-width: $breakpoint-xs) {
top: 2px;
right: 2px;
}
}
}
}
</style>

View file

@ -1,295 +0,0 @@
<template>
<ModalDrawer
:name="VALUE_SURVEY_MODAL_KEY"
:event-bus="modalBus"
:before-close="closeDialog"
:modal="false"
:wrapper-closable="false"
direction="btt"
width="120px"
:class="$style.valueSurvey"
>
<template #header>
<div :class="$style.title">
<n8n-heading tag="h2" size="medium" color="text-xlight">{{ getTitle }}</n8n-heading>
</div>
</template>
<template #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-button
type="tertiary"
:label="(value - 1).toString()"
square
@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"
@update:model-value="onInputChange"
/>
<div :class="$style.button">
<n8n-button label="Send" float="right" :disabled="!isEmailValid" @click="send" />
</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 { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import { VALID_EMAIL_REGEX, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import type { IN8nPromptResponse } from '@/Interface';
import ModalDrawer from '@/components/ModalDrawer.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { createEventBus } from 'n8n-design-system/utils';
import { useToast } from '@/composables/useToast';
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 defineComponent({
name: 'ValueSurvey',
components: {
ModalDrawer,
},
props: ['isActive'],
setup() {
return {
...useToast(),
};
},
watch: {
isActive(isActive) {
if (isActive) {
this.$telemetry.track('User shown value survey', {
instance_id: this.rootStore.instanceId,
});
}
},
},
computed: {
...mapStores(useRootStore, useSettingsStore),
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,
modalBus: createEventBus(),
};
},
methods: {
closeDialog(): void {
if (this.form.value === '') {
this.$telemetry.track('User responded value survey score', {
instance_id: this.rootStore.instanceId,
nps: '',
});
}
if (this.form.value !== '' && this.form.email === '') {
this.$telemetry.track('User responded value survey email', {
instance_id: this.rootStore.instanceId,
email: '',
});
}
},
onInputChange(value: string) {
this.form.email = value;
},
async selectSurveyValue(value: string) {
this.form.value = value;
this.showButtons = false;
const response: IN8nPromptResponse | undefined = await this.settingsStore.submitValueSurvey({
value: this.form.value,
});
if (response && response.updated) {
this.$telemetry.track('User responded value survey score', {
instance_id: this.rootStore.instanceId,
nps: this.form.value,
});
}
},
async send() {
if (this.isEmailValid) {
const response: IN8nPromptResponse | undefined = await this.settingsStore.submitValueSurvey(
{
email: this.form.email,
value: this.form.value,
},
);
if (response && response.updated) {
this.$telemetry.track('User responded value survey email', {
instance_id: this.rootStore.instanceId,
email: this.form.email,
});
this.showMessage({
title: 'Thanks for your feedback',
message:
'If youd like to help even more, leave us a <a target="_blank" href="https://www.g2.com/products/n8n/reviews/start">review on G2</a>.',
type: 'success',
duration: 15000,
});
}
setTimeout(() => {
this.form.value = '';
this.form.email = '';
this.showButtons = true;
}, 1000);
this.modalBus.emit('close');
}
},
},
});
</script>
<style module lang="scss">
.title {
height: 16px;
text-align: center;
@media (max-width: $breakpoint-xs) {
margin-top: 10px;
padding: 0 15px;
}
h2 {
color: var(--color-value-survey-font);
}
}
.content {
display: flex;
justify-content: center;
@media (max-width: $breakpoint-xs) {
margin-top: 20px;
}
}
.wrapper {
display: flex;
flex-direction: column;
.text span {
color: var(--color-value-survey-font);
}
}
.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);
}
.valueSurvey {
background: var(--color-value-survey-background);
height: 120px;
top: auto;
@media (max-width: $breakpoint-xs) {
height: 140px;
}
@media (max-width: $breakpoint-xs) {
height: 140px !important;
}
header {
height: 50px;
margin: 0;
padding: 18px 0 16px;
button {
top: 12px;
right: 16px;
position: absolute;
font-weight: var(--font-weight-bold);
color: var(--color-value-survey-font);
@media (max-width: $breakpoint-xs) {
top: 2px;
right: 2px;
}
}
}
}
</style>

View file

@ -47,6 +47,7 @@ import { PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_SETTINGS_MODAL_KEY } from '@/co
import type { IWorkflowSettings } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
interface IWorkflowSaveSettings {
saveFailedExecutions: boolean;
@ -85,7 +86,7 @@ export default defineComponent({
};
},
computed: {
...mapStores(useRootStore, useSettingsStore, useUIStore, useWorkflowsStore),
...mapStores(useRootStore, useSettingsStore, useUIStore, useWorkflowsStore, useNpsSurveyStore),
accordionItems(): object[] {
return [
{
@ -228,7 +229,9 @@ export default defineComponent({
name: this.workflowName,
tags: this.currentWorkflowTagIds,
});
if (saved) await this.settingsStore.fetchPromptsData();
if (saved) {
await this.npsSurveyStore.fetchPromptsData();
}
},
},
});

View file

@ -49,6 +49,7 @@ import { useTagsStore } from '@/stores/tags.store';
import { executionFilterToQueryFilter } from '@/utils/executionUtils';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useDebounce } from '@/composables/useDebounce';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
export default defineComponent({
name: 'WorkflowExecutionsList',
@ -79,7 +80,7 @@ export default defineComponent({
if (confirmModal === MODAL_CONFIRM) {
const saved = await this.workflowHelpers.saveCurrentWorkflow({}, false);
if (saved) {
await this.settingsStore.fetchPromptsData();
await this.npsSurveyStore.fetchPromptsData();
}
this.uiStore.stateIsDirty = false;
next();
@ -141,7 +142,7 @@ export default defineComponent({
};
},
computed: {
...mapStores(useTagsStore, useNodeTypesStore, useSettingsStore, useUIStore),
...mapStores(useTagsStore, useNodeTypesStore, useSettingsStore, useUIStore, useNpsSurveyStore),
temporaryExecution(): ExecutionSummary | undefined {
const isTemporary = !this.executions.find((execution) => execution.id === this.execution?.id);
return isTemporary ? this.execution : undefined;

View file

@ -6,7 +6,6 @@ import {
WORKFLOW_ACTIVE_MODAL_KEY,
} from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useRouter } from 'vue-router';
@ -15,6 +14,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n';
import { ref } from 'vue';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
export function useWorkflowActivate() {
const updatingWorkflowActivation = ref(false);
@ -22,11 +22,11 @@ export function useWorkflowActivate() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const telemetry = useTelemetry();
const toast = useToast();
const i18n = useI18n();
const npsSurveyStore = useNpsSurveyStore();
//methods
@ -117,7 +117,7 @@ export function useWorkflowActivate() {
if (newActiveState && useStorage(LOCAL_STORAGE_ACTIVATION_FLAG).value !== 'true') {
uiStore.openModal(WORKFLOW_ACTIVE_MODAL_KEY);
} else {
await settingsStore.fetchPromptsData();
await npsSurveyStore.fetchPromptsData();
}
}
};

View file

@ -50,7 +50,7 @@ export const WORKFLOW_LM_CHAT_MODAL_KEY = 'lmChat';
export const WORKFLOW_SHARE_MODAL_KEY = 'workflowShare';
export const PERSONALIZATION_MODAL_KEY = 'personalization';
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey';
export const NPS_SURVEY_MODAL_KEY = 'npsSurvey';
export const WORKFLOW_ACTIVE_MODAL_KEY = 'activation';
export const ONBOARDING_CALL_SIGNUP_MODAL_KEY = 'onboardingCallSignup';
export const COMMUNITY_PACKAGE_INSTALL_MODAL_KEY = 'communityPackageInstall';
@ -767,6 +767,10 @@ export const TIME = {
DAY: 24 * 60 * 60 * 1000,
};
export const THREE_DAYS_IN_MILLIS = 3 * TIME.DAY;
export const SEVEN_DAYS_IN_MILLIS = 7 * TIME.DAY;
export const SIX_MONTHS_IN_MILLIS = 6 * 30 * TIME.DAY;
/**
* Mouse button codes
*/

View file

@ -1449,6 +1449,16 @@
"pushConnection.executionError": "There was a problem executing the workflow{error}",
"pushConnection.executionError.openNode": " <a data-action='openNodeDetail' data-action-parameter-node='{node}'>Open node</a>",
"pushConnection.executionError.details": "<br /><strong>{details}</strong>",
"prompts.productTeamMessage": "Our product team will get in touch personally",
"prompts.npsSurvey.recommendationQuestion": "How likely are you to recommend n8n to a friend or colleague?",
"prompts.npsSurvey.greatFeedbackTitle": "Great to hear! Can we reach out to see how we can make n8n even better for you?",
"prompts.npsSurvey.defaultFeedbackTitle": "Thanks for your feedback! We'd love to understand how we can improve. Can we reach out?",
"prompts.npsSurvey.notLikely": "Not likely",
"prompts.npsSurvey.veryLikely": "Very likely",
"prompts.npsSurvey.send": "Send",
"prompts.npsSurvey.yourEmailAddress": "Your email address",
"prompts.npsSurvey.reviewUs": "If youd like to help even more, leave us a <a target=\"_blank\" href=\"https://www.g2.com/products/n8n/reviews/start\">review on G2</a>.",
"prompts.npsSurvey.thanks": "Thanks for your feedback",
"resourceLocator.id.placeholder": "Enter ID...",
"resourceLocator.mode.id": "By ID",
"resourceLocator.mode.url": "By URL",

View file

@ -3,7 +3,7 @@ import type { ITelemetrySettings, ITelemetryTrackProperties, IDataObject } from
import type { RouteLocation } from 'vue-router';
import type { INodeCreateElement, IUpdateInformation } from '@/Interface';
import type { IUserNodesPanelSession } from './telemetry.types';
import type { IUserNodesPanelSession, RudderStack } from './telemetry.types';
import {
APPEND_ATTRIBUTION_DEFAULT_PATH,
MICROSOFT_TEAMS_NODE_TYPE,
@ -22,7 +22,7 @@ export class Telemetry {
private previousPath: string;
private get rudderStack() {
private get rudderStack(): RudderStack | undefined {
return window.rudderanalytics;
}
@ -92,12 +92,12 @@ export class Telemetry {
traits.user_cloud_id = settingsStore.settings?.n8nMetadata?.userId ?? '';
}
if (userId) {
this.rudderStack.identify(
this.rudderStack?.identify(
`${instanceId}#${userId}${projectId ? '#' + projectId : ''}`,
traits,
);
} else {
this.rudderStack.reset();
this.rudderStack?.reset();
}
}
@ -282,6 +282,9 @@ export class Telemetry {
private initRudderStack(key: string, url: string, options: IDataObject) {
window.rudderanalytics = window.rudderanalytics || [];
if (!this.rudderStack) {
return;
}
this.rudderStack.methods = [
'load',
@ -298,6 +301,10 @@ export class Telemetry {
this.rudderStack.factory = (method: string) => {
return (...args: unknown[]) => {
if (!this.rudderStack) {
throw new Error('RudderStack not initialized');
}
const argsCopy = [method, ...args];
this.rudderStack.push(argsCopy);

View file

@ -19,7 +19,7 @@ interface IUserNodesPanelSessionData {
* Simplified version of:
* https://github.com/rudderlabs/rudder-sdk-js/blob/master/dist/rudder-sdk-js/index.d.ts
*/
interface RudderStack extends Array<unknown> {
export interface RudderStack extends Array<unknown> {
[key: string]: unknown;
methods: string[];

View file

@ -0,0 +1,303 @@
import { createPinia, setActivePinia } from 'pinia';
import { useNpsSurveyStore } from './npsSurvey.store';
import { THREE_DAYS_IN_MILLIS, TIME, NPS_SURVEY_MODAL_KEY } from '@/constants';
import { useSettingsStore } from './settings.store';
const { openModal, updateNpsSurveyState } = vi.hoisted(() => {
return {
openModal: vi.fn(),
updateNpsSurveyState: vi.fn(),
};
});
vi.mock('@/stores/ui.store', () => ({
useUIStore: vi.fn(() => ({
openModal,
})),
}));
vi.mock('@/api/npsSurvey', () => ({
updateNpsSurveyState,
}));
const NOW = 1717602004819;
vi.useFakeTimers({
now: NOW,
});
describe('useNpsSurvey', () => {
let npsSurveyStore: ReturnType<typeof useNpsSurveyStore>;
beforeEach(() => {
vi.restoreAllMocks();
setActivePinia(createPinia());
useSettingsStore().settings.telemetry = { enabled: true };
npsSurveyStore = useNpsSurveyStore();
});
it('by default, without login, does not show survey', async () => {
await npsSurveyStore.showNpsSurveyIfPossible();
expect(openModal).not.toHaveBeenCalled();
expect(updateNpsSurveyState).not.toHaveBeenCalled();
});
it('does not show nps survey if user activated less than 3 days ago', async () => {
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - THREE_DAYS_IN_MILLIS + 10000,
});
await npsSurveyStore.showNpsSurveyIfPossible();
expect(openModal).not.toHaveBeenCalled();
expect(updateNpsSurveyState).not.toHaveBeenCalled();
});
it('shows nps survey if user activated more than 3 days ago and has yet to see survey', async () => {
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - THREE_DAYS_IN_MILLIS - 10000,
});
await npsSurveyStore.showNpsSurveyIfPossible();
expect(openModal).toHaveBeenCalledWith(NPS_SURVEY_MODAL_KEY);
expect(updateNpsSurveyState).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: '/rest',
}),
{
ignoredCount: 0,
lastShownAt: NOW,
waitingForResponse: true,
},
);
});
it('does not show nps survey if user has seen and responded to survey less than 6 months ago', async () => {
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - 10 * TIME.DAY,
npsSurvey: {
responded: true,
lastShownAt: NOW - 2 * TIME.DAY,
},
});
await npsSurveyStore.showNpsSurveyIfPossible();
expect(openModal).not.toHaveBeenCalled();
expect(updateNpsSurveyState).not.toHaveBeenCalledWith();
});
it('does not show nps survey if user has responded survey more than 7 days ago', async () => {
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - 10 * TIME.DAY,
npsSurvey: {
responded: true,
lastShownAt: NOW - 8 * TIME.DAY,
},
});
await npsSurveyStore.showNpsSurveyIfPossible();
expect(openModal).not.toHaveBeenCalled();
expect(updateNpsSurveyState).not.toHaveBeenCalled();
});
it('shows nps survey if user has responded survey more than 6 months ago', async () => {
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - 30 * 7 * TIME.DAY,
npsSurvey: {
responded: true,
lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY,
},
});
await npsSurveyStore.showNpsSurveyIfPossible();
expect(openModal).toHaveBeenCalledWith(NPS_SURVEY_MODAL_KEY);
expect(updateNpsSurveyState).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: '/rest',
}),
{
ignoredCount: 0,
lastShownAt: NOW,
waitingForResponse: true,
},
);
});
it('does not show nps survey if user has ignored survey less than 7 days ago', async () => {
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - 10 * TIME.DAY,
npsSurvey: {
waitingForResponse: true,
lastShownAt: NOW - 5 * TIME.DAY,
ignoredCount: 0,
},
});
await npsSurveyStore.showNpsSurveyIfPossible();
expect(openModal).not.toHaveBeenCalled();
expect(updateNpsSurveyState).not.toHaveBeenCalled();
});
it('shows nps survey if user has ignored survey more than 7 days ago', async () => {
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - 10 * TIME.DAY,
npsSurvey: {
waitingForResponse: true,
lastShownAt: NOW - 8 * TIME.DAY,
ignoredCount: 0,
},
});
await npsSurveyStore.showNpsSurveyIfPossible();
expect(openModal).toHaveBeenCalledWith(NPS_SURVEY_MODAL_KEY);
expect(updateNpsSurveyState).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: '/rest',
}),
{
ignoredCount: 0,
lastShownAt: NOW,
waitingForResponse: true,
},
);
});
it('increments ignore count when survey is ignored', async () => {
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - 30 * 7 * TIME.DAY,
npsSurvey: {
responded: true,
lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY,
},
});
await npsSurveyStore.ignoreNpsSurvey();
expect(updateNpsSurveyState).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: '/rest',
}),
{
ignoredCount: 1,
lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY,
waitingForResponse: true,
},
);
});
it('updates state to responded if ignored more than maximum times', async () => {
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - 30 * 7 * TIME.DAY,
npsSurvey: {
waitingForResponse: true,
lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY,
ignoredCount: 2,
},
});
await npsSurveyStore.ignoreNpsSurvey();
expect(updateNpsSurveyState).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: '/rest',
}),
{
lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY,
responded: true,
},
);
});
it('updates state to responded when response is given', async () => {
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - 30 * 7 * TIME.DAY,
npsSurvey: {
responded: true,
lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY,
},
});
await npsSurveyStore.respondNpsSurvey();
expect(updateNpsSurveyState).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: '/rest',
}),
{
responded: true,
lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY,
},
);
});
it('does not show nps survey twice in the same session', async () => {
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - THREE_DAYS_IN_MILLIS - 10000,
});
await npsSurveyStore.showNpsSurveyIfPossible();
expect(openModal).toHaveBeenCalledWith(NPS_SURVEY_MODAL_KEY);
expect(updateNpsSurveyState).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: '/rest',
}),
{
ignoredCount: 0,
lastShownAt: NOW,
waitingForResponse: true,
},
);
openModal.mockReset();
updateNpsSurveyState.mockReset();
await npsSurveyStore.showNpsSurveyIfPossible();
expect(openModal).not.toHaveBeenCalled();
expect(updateNpsSurveyState).not.toHaveBeenCalled();
});
it('resets on logout, preventing nps survey from showing', async () => {
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - THREE_DAYS_IN_MILLIS - 10000,
});
npsSurveyStore.resetNpsSurveyOnLogOut();
await npsSurveyStore.showNpsSurveyIfPossible();
expect(openModal).not.toHaveBeenCalled();
expect(updateNpsSurveyState).not.toHaveBeenCalled();
});
it('if telemetry is disabled, does not show nps survey', async () => {
useSettingsStore().settings.telemetry = { enabled: false };
npsSurveyStore.setupNpsSurveyOnLogin('1', {
userActivated: true,
userActivatedAt: NOW - THREE_DAYS_IN_MILLIS - 10000,
});
await npsSurveyStore.showNpsSurveyIfPossible();
expect(openModal).not.toHaveBeenCalled();
expect(updateNpsSurveyState).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,166 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { useUIStore } from './ui.store';
import {
SEVEN_DAYS_IN_MILLIS,
SIX_MONTHS_IN_MILLIS,
THREE_DAYS_IN_MILLIS,
NPS_SURVEY_MODAL_KEY,
CONTACT_PROMPT_MODAL_KEY,
} from '@/constants';
import { useRootStore } from './n8nRoot.store';
import type { IUserSettings, NpsSurveyState } from 'n8n-workflow';
import { useSettingsStore } from './settings.store';
import { updateNpsSurveyState } from '@/api/npsSurvey';
import type { IN8nPrompts } from '@/Interface';
import { getPromptsData } from '@/api/settings';
import { assert } from '@/utils/assert';
export const MAXIMUM_TIMES_TO_SHOW_SURVEY_IF_IGNORED = 3;
export const useNpsSurveyStore = defineStore('npsSurvey', () => {
const rootStore = useRootStore();
const uiStore = useUIStore();
const settingsStore = useSettingsStore();
const shouldShowNpsSurveyNext = ref<boolean>(false);
const currentSurveyState = ref<NpsSurveyState | undefined>();
const currentUserId = ref<string | undefined>();
const promptsData = ref<IN8nPrompts | undefined>();
function setupNpsSurveyOnLogin(userId: string, settings?: IUserSettings): void {
currentUserId.value = userId;
if (settings) {
setShouldShowNpsSurvey(settings);
}
}
function setShouldShowNpsSurvey(settings: IUserSettings) {
if (!settingsStore.isTelemetryEnabled) {
shouldShowNpsSurveyNext.value = false;
return;
}
currentSurveyState.value = settings.npsSurvey;
const userActivated = Boolean(settings.userActivated);
const userActivatedAt = settings.userActivatedAt;
const lastShownAt = currentSurveyState.value?.lastShownAt;
if (!userActivated || !userActivatedAt) {
return;
}
const timeSinceActivation = Date.now() - userActivatedAt;
if (timeSinceActivation < THREE_DAYS_IN_MILLIS) {
return;
}
if (!currentSurveyState.value || !lastShownAt) {
// user has activated but never seen the nps survey
shouldShowNpsSurveyNext.value = true;
return;
}
const timeSinceLastShown = Date.now() - lastShownAt;
if ('responded' in currentSurveyState.value && timeSinceLastShown < SIX_MONTHS_IN_MILLIS) {
return;
}
if (
'waitingForResponse' in currentSurveyState.value &&
timeSinceLastShown < SEVEN_DAYS_IN_MILLIS
) {
return;
}
shouldShowNpsSurveyNext.value = true;
}
function resetNpsSurveyOnLogOut() {
shouldShowNpsSurveyNext.value = false;
}
async function showNpsSurveyIfPossible() {
if (!shouldShowNpsSurveyNext.value) {
return;
}
uiStore.openModal(NPS_SURVEY_MODAL_KEY);
shouldShowNpsSurveyNext.value = false;
const updatedState: NpsSurveyState = {
waitingForResponse: true,
lastShownAt: Date.now(),
ignoredCount:
currentSurveyState.value && 'ignoredCount' in currentSurveyState.value
? currentSurveyState.value.ignoredCount
: 0,
};
await updateNpsSurveyState(rootStore.getRestApiContext, updatedState);
currentSurveyState.value = updatedState;
}
async function respondNpsSurvey() {
assert(currentSurveyState.value);
const updatedState: NpsSurveyState = {
responded: true,
lastShownAt: currentSurveyState.value.lastShownAt,
};
await updateNpsSurveyState(rootStore.getRestApiContext, updatedState);
currentSurveyState.value = updatedState;
}
async function ignoreNpsSurvey() {
assert(currentSurveyState.value);
const state = currentSurveyState.value;
const ignoredCount = 'ignoredCount' in state ? state.ignoredCount : 0;
if (ignoredCount + 1 >= MAXIMUM_TIMES_TO_SHOW_SURVEY_IF_IGNORED) {
await respondNpsSurvey();
return;
}
const updatedState: NpsSurveyState = {
waitingForResponse: true,
lastShownAt: currentSurveyState.value.lastShownAt,
ignoredCount: ignoredCount + 1,
};
await updateNpsSurveyState(rootStore.getRestApiContext, updatedState);
currentSurveyState.value = updatedState;
}
async function fetchPromptsData(): Promise<void> {
assert(currentUserId.value);
if (!settingsStore.isTelemetryEnabled) {
return;
}
try {
promptsData.value = await getPromptsData(
settingsStore.settings.instanceId,
currentUserId.value,
);
} catch (e) {
console.error('Failed to fetch prompts data');
}
if (promptsData.value?.showContactPrompt) {
uiStore.openModal(CONTACT_PROMPT_MODAL_KEY);
} else {
await useNpsSurveyStore().showNpsSurveyIfPossible();
}
}
return {
promptsData,
resetNpsSurveyOnLogOut,
showNpsSurveyIfPossible,
ignoreNpsSurvey,
respondNpsSurvey,
setupNpsSurveyOnLogin,
fetchPromptsData,
};
});

View file

@ -6,22 +6,15 @@ import {
testLdapConnection,
updateLdapConfig,
} from '@/api/ldap';
import { getPromptsData, getSettings, submitContactInfo, submitValueSurvey } from '@/api/settings';
import { getSettings, submitContactInfo } from '@/api/settings';
import { testHealthEndpoint } from '@/api/templates';
import type { EnterpriseEditionFeatureValue } from '@/Interface';
import {
CONTACT_PROMPT_MODAL_KEY,
STORES,
VALUE_SURVEY_MODAL_KEY,
INSECURE_CONNECTION_WARNING,
} from '@/constants';
import type {
EnterpriseEditionFeatureValue,
ILdapConfig,
IN8nPromptResponse,
IN8nPrompts,
IN8nValueSurveyData,
ISettingsState,
} from '@/Interface';
import { STORES, INSECURE_CONNECTION_WARNING } from '@/constants';
import { UserManagementAuthenticationMethod } from '@/Interface';
import type {
IDataObject,
@ -45,7 +38,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
state: (): ISettingsState => ({
initialized: false,
settings: {} as IN8nUISettings,
promptsData: {} as IN8nPrompts,
userManagement: {
quota: -1,
showSetupOnFirstLoad: false,
@ -311,32 +303,9 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
},
};
},
setPromptsData(promptsData: IN8nPrompts): void {
this.promptsData = promptsData;
},
setAllowedModules(allowedModules: { builtIn?: string[]; external?: string[] }): void {
this.settings.allowedModules = allowedModules;
},
async fetchPromptsData(): Promise<void> {
if (!this.isTelemetryEnabled) {
return;
}
const uiStore = useUIStore();
const usersStore = useUsersStore();
const promptsData: IN8nPrompts = await getPromptsData(
this.settings.instanceId,
usersStore.currentUserId || '',
);
if (promptsData && promptsData.showContactPrompt) {
uiStore.openModal(CONTACT_PROMPT_MODAL_KEY);
} else if (promptsData && promptsData.showValueSurvey) {
uiStore.openModal(VALUE_SURVEY_MODAL_KEY);
}
this.setPromptsData(promptsData);
},
async submitContactInfo(email: string): Promise<IN8nPromptResponse | undefined> {
try {
const usersStore = useUsersStore();
@ -349,18 +318,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
return;
}
},
async submitValueSurvey(params: IN8nValueSurveyData): Promise<IN8nPromptResponse | undefined> {
try {
const usersStore = useUsersStore();
return await submitValueSurvey(
this.settings.instanceId,
usersStore.currentUserId || '',
params,
);
} catch (error) {
return;
}
},
async testTemplatesEndpoint(): Promise<void> {
const timeout = new Promise((_, reject) => setTimeout(() => reject(), 2000));
await Promise.race([testHealthEndpoint(this.templatesHost), timeout]);

View file

@ -24,7 +24,7 @@ import {
PERSONALIZATION_MODAL_KEY,
STORES,
TAGS_MANAGER_MODAL_KEY,
VALUE_SURVEY_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
VERSIONS_MODAL_KEY,
VIEWS,
WORKFLOW_ACTIVE_MODAL_KEY,
@ -55,6 +55,7 @@ import type {
AppliedThemeOption,
NotificationOptions,
ModalState,
ModalKey,
} from '@/Interface';
import { defineStore } from 'pinia';
import { useRootStore } from '@/stores/n8nRoot.store';
@ -104,7 +105,7 @@ export const useUIStore = defineStore(STORES.UI, {
PERSONALIZATION_MODAL_KEY,
INVITE_USER_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
VALUE_SURVEY_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
VERSIONS_MODAL_KEY,
WORKFLOW_LM_CHAT_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
@ -278,19 +279,19 @@ export const useUIStore = defineStore(STORES.UI, {
return this.modals[VERSIONS_MODAL_KEY].open;
},
isModalOpen() {
return (name: string) => this.modals[name].open;
return (name: ModalKey) => this.modals[name].open;
},
isModalActive() {
return (name: string) => this.modalStack.length > 0 && name === this.modalStack[0];
return (name: ModalKey) => this.modalStack.length > 0 && name === this.modalStack[0];
},
getModalActiveId() {
return (name: string) => this.modals[name].activeId;
return (name: ModalKey) => this.modals[name].activeId;
},
getModalMode() {
return (name: string) => this.modals[name].mode;
return (name: ModalKey) => this.modals[name].mode;
},
getModalData() {
return (name: string) => this.modals[name].data;
return (name: ModalKey) => this.modals[name].data;
},
getFakeDoorByLocation() {
return (location: IFakeDoorLocation) =>

View file

@ -42,6 +42,7 @@ import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans';
import { useRBACStore } from '@/stores/rbac.store';
import type { Scope } from '@n8n/permissions';
import { inviteUsers, acceptInvitation } from '@/api/invitation';
import { useNpsSurveyStore } from './npsSurvey.store';
const isPendingUser = (user: IUserResponse | null) => !!user?.isPending;
const isInstanceOwner = (user: IUserResponse | null) => user?.role === ROLE.Owner;
@ -110,6 +111,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
const defaultScopes: Scope[] = [];
useRBACStore().setGlobalScopes(user.globalScopes || defaultScopes);
usePostHog().init(user.featureFlags);
useNpsSurveyStore().setupNpsSurveyOnLogin(user.id, user.settings);
},
unsetCurrentUser() {
this.currentUserId = null;
@ -185,6 +187,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
useCloudPlanStore().reset();
usePostHog().reset();
useUIStore().clearBannerStack();
useNpsSurveyStore().resetNpsSurveyOnLogOut();
},
async createOwner(params: {
firstName: string;

View file

@ -403,6 +403,7 @@ import { useAIStore } from '@/stores/ai.store';
import { useStorage } from '@/composables/useStorage';
import { isJSPlumbEndpointElement, isJSPlumbConnection } from '@/utils/typeGuards';
import { usePostHog } from '@/stores/posthog.store';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
interface AddNodeOptions {
position?: XYPosition;
@ -464,7 +465,7 @@ export default defineComponent({
this.workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
const saved = await this.workflowHelpers.saveCurrentWorkflow({}, false);
if (saved) {
await this.settingsStore.fetchPromptsData();
await this.npsSurveyStore.fetchPromptsData();
}
this.uiStore.stateIsDirty = false;
@ -605,6 +606,7 @@ export default defineComponent({
useExecutionsStore,
useProjectsStore,
useAIStore,
useNpsSurveyStore,
),
nativelyNumberSuffixedDefaults(): string[] {
return this.nodeTypesStore.nativelyNumberSuffixedDefaults;
@ -1235,7 +1237,7 @@ export default defineComponent({
async onSaveKeyboardShortcut(e: KeyboardEvent) {
let saved = await this.workflowHelpers.saveCurrentWorkflow();
if (saved) {
await this.settingsStore.fetchPromptsData();
await this.npsSurveyStore.fetchPromptsData();
if (this.$route.name === VIEWS.EXECUTION_DEBUG) {
await this.$router.replace({
@ -3796,7 +3798,7 @@ export default defineComponent({
);
if (confirmModal === MODAL_CONFIRM) {
const saved = await this.workflowHelpers.saveCurrentWorkflow();
if (saved) await this.settingsStore.fetchPromptsData();
if (saved) await this.npsSurveyStore.fetchPromptsData();
} else if (confirmModal === MODAL_CANCEL) {
return;
}

View file

@ -2517,11 +2517,21 @@ export interface IUserManagementSettings {
authenticationMethod: AuthenticationMethod;
}
export type NpsSurveyRespondedState = { lastShownAt: number; responded: true };
export type NpsSurveyWaitingState = {
lastShownAt: number;
waitingForResponse: true;
ignoredCount: number;
};
export type NpsSurveyState = NpsSurveyRespondedState | NpsSurveyWaitingState;
export interface IUserSettings {
isOnboarded?: boolean;
firstSuccessfulWorkflowId?: string;
userActivated?: boolean;
userActivatedAt?: number;
allowSSOManualLogin?: boolean;
npsSurvey?: NpsSurveyState;
}
export interface IPublicApiSettings {

View file

@ -125,6 +125,9 @@ importers:
'@ngneat/falso':
specifier: ^6.4.0
version: 6.4.0
'@sinonjs/fake-timers':
specifier: ^11.2.2
version: 11.2.2
cross-env:
specifier: ^7.0.3
version: 7.0.3
@ -4671,9 +4674,15 @@ packages:
'@sinonjs/commons@2.0.0':
resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==}
'@sinonjs/commons@3.0.1':
resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
'@sinonjs/fake-timers@10.0.2':
resolution: {integrity: sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==}
'@sinonjs/fake-timers@11.2.2':
resolution: {integrity: sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==}
'@smithy/abort-controller@2.0.15':
resolution: {integrity: sha512-JkS36PIS3/UCbq/MaozzV7jECeL+BTt4R75bwY8i+4RASys4xOyUS1HsRyUNSqUXFP4QyCz5aNnh3ltuaxv+pw==}
engines: {node: '>=14.0.0'}
@ -13775,9 +13784,6 @@ packages:
zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
onlyBuiltDependencies:
- sqlite3
snapshots:
'@aashutoshrathi/word-wrap@1.2.6': {}
@ -17781,10 +17787,18 @@ snapshots:
dependencies:
type-detect: 4.0.8
'@sinonjs/commons@3.0.1':
dependencies:
type-detect: 4.0.8
'@sinonjs/fake-timers@10.0.2':
dependencies:
'@sinonjs/commons': 2.0.0
'@sinonjs/fake-timers@11.2.2':
dependencies:
'@sinonjs/commons': 3.0.1
'@smithy/abort-controller@2.0.15':
dependencies:
'@smithy/types': 2.12.0