mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
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:
parent
aaa78435b0
commit
50bd5b9080
|
@ -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', () => {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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‘');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
143
cypress/e2e/42-nps-survey.cy.ts
Normal file
143
cypress/e2e/42-nps-survey.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
17
cypress/pages/notifications.ts
Normal file
17
cypress/pages/notifications.ts
Normal 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 });
|
||||
};
|
16
cypress/pages/npsSurvey.ts
Normal file
16
cypress/pages/npsSurvey.ts
Normal 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
|
||||
*/
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -54,6 +54,7 @@ declare global {
|
|||
}
|
||||
>;
|
||||
resetDatabase(): void;
|
||||
setAppDate(targetDate: number | Date): void;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
52
packages/cli/src/controllers/userSettings.controller.ts
Normal file
52
packages/cli/src/controllers/userSettings.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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')`,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -63,6 +63,7 @@ export class EventsService extends EventEmitter {
|
|||
await Container.get(UserService).updateSettings(owner.id, {
|
||||
firstSuccessfulWorkflowId: workflowId,
|
||||
userActivated: true,
|
||||
userActivatedAt: runData.startedAt.getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
7
packages/editor-ui/src/api/npsSurvey.ts
Normal file
7
packages/editor-ui/src/api/npsSurvey.ts
Normal 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);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 'You’re 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.';
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
283
packages/editor-ui/src/components/NpsSurvey.vue
Normal file
283
packages/editor-ui/src/components/NpsSurvey.vue
Normal 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>
|
|
@ -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 you’d 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>
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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 you’d 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",
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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[];
|
||||
|
|
303
packages/editor-ui/src/stores/npsStore.store.spec.ts
Normal file
303
packages/editor-ui/src/stores/npsStore.store.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
166
packages/editor-ui/src/stores/npsSurvey.store.ts
Normal file
166
packages/editor-ui/src/stores/npsSurvey.store.ts
Normal 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,
|
||||
};
|
||||
});
|
|
@ -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]);
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue