mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Add behavioral and coaching call popups
Summary: Adds a new category of popups that are shown dynamically when certain parts of the UI are first rendered, and a free coaching call popup that's shown to users on their site home page. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3706
This commit is contained in:
137
test/nbrowser/BehavioralPrompts.ts
Normal file
137
test/nbrowser/BehavioralPrompts.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import {assert, driver, Key} from 'mocha-webdriver';
|
||||
import * as gu from 'test/nbrowser/gristUtils';
|
||||
import {setupTestSuite} from 'test/nbrowser/testUtils';
|
||||
|
||||
describe('BehavioralPrompts', function() {
|
||||
this.timeout(20000);
|
||||
const cleanup = setupTestSuite();
|
||||
|
||||
let session: gu.Session;
|
||||
let docId: string;
|
||||
|
||||
before(async () => {
|
||||
session = await gu.session().user('user1').login({showTips: true});
|
||||
docId = await session.tempNewDoc(cleanup, 'BehavioralPrompts');
|
||||
});
|
||||
|
||||
afterEach(() => gu.checkForErrors());
|
||||
|
||||
it('should be shown when the column type select menu is opened', async function() {
|
||||
await assertPromptTitle(null);
|
||||
await gu.toggleSidePanel('right', 'open');
|
||||
await driver.find('.test-right-tab-field').click();
|
||||
await driver.find('.test-fbuilder-type-select').click();
|
||||
await assertPromptTitle('Reference Columns');
|
||||
});
|
||||
|
||||
it('should be temporarily dismissed on click-away', async function() {
|
||||
await gu.getCell({col: 'A', rowNum: 1}).click();
|
||||
await assertPromptTitle(null);
|
||||
});
|
||||
|
||||
it('should be shown again the next time the menu is opened', async function() {
|
||||
await driver.find('.test-fbuilder-type-select').click();
|
||||
await assertPromptTitle('Reference Columns');
|
||||
});
|
||||
|
||||
it('should be permanently dismissed when "Got it" is clicked', async function() {
|
||||
await gu.dismissBehavioralPrompts();
|
||||
await assertPromptTitle(null);
|
||||
|
||||
// Refresh the page and make sure the prompt isn't shown again.
|
||||
await session.loadDoc(`/doc/${docId}`);
|
||||
await driver.find('.test-fbuilder-type-select').click();
|
||||
await assertPromptTitle(null);
|
||||
await gu.sendKeys(Key.ESCAPE);
|
||||
});
|
||||
|
||||
it('should be shown after selecting a reference column type', async function() {
|
||||
await gu.setType(/Reference$/);
|
||||
await assertPromptTitle('Reference Columns');
|
||||
await gu.undo();
|
||||
});
|
||||
|
||||
it('should be shown after selecting a reference list column type', async function() {
|
||||
await gu.setType(/Reference List$/);
|
||||
await assertPromptTitle('Reference Columns');
|
||||
});
|
||||
|
||||
it('should be shown when opening the Raw Data page', async function() {
|
||||
await driver.find('.test-tools-raw').click();
|
||||
await assertPromptTitle('Raw Data page');
|
||||
});
|
||||
|
||||
it('should be shown when opening the Access Rules page', async function() {
|
||||
await driver.find('.test-tools-access-rules').click();
|
||||
await assertPromptTitle('Access Rules');
|
||||
});
|
||||
|
||||
it('should be shown when opening the filter menu', async function() {
|
||||
await gu.openPage('Table1');
|
||||
await gu.openColumnMenu('A', 'Filter');
|
||||
await assertPromptTitle('Filter Buttons');
|
||||
});
|
||||
|
||||
it('should be shown when adding a second pinned filter', async function() {
|
||||
await driver.find('.test-filter-menu-apply-btn').click();
|
||||
await assertPromptTitle(null);
|
||||
await gu.openColumnMenu('B', 'Filter');
|
||||
await driver.find('.test-filter-menu-apply-btn').click();
|
||||
await assertPromptTitle('Nested Filtering');
|
||||
});
|
||||
|
||||
it('should be shown when opening the page widget picker', async function() {
|
||||
await gu.openAddWidgetToPage();
|
||||
await assertPromptTitle('Selecting Data');
|
||||
await gu.dismissBehavioralPrompts();
|
||||
});
|
||||
|
||||
it('should be shown when select by is an available option', async function() {
|
||||
await driver.findContent('.test-wselect-table', /Table1/).click();
|
||||
await assertPromptTitle('Linking Widgets');
|
||||
await gu.dismissBehavioralPrompts();
|
||||
});
|
||||
|
||||
it('should be shown when adding a card widget', async function() {
|
||||
await gu.selectWidget('Card', /Table1/);
|
||||
await assertPromptTitle('Editing Card Layout');
|
||||
});
|
||||
|
||||
it('should not be shown when adding a non-card widget', async function() {
|
||||
await gu.addNewPage('Table', /Table1/);
|
||||
await assertPromptTitle(null);
|
||||
});
|
||||
|
||||
it('should be shown when adding a card list widget', async function() {
|
||||
await gu.addNewPage('Card List', /Table1/);
|
||||
await assertPromptTitle('Editing Card Layout');
|
||||
});
|
||||
|
||||
it(`should stop showing tips if "Don't show tips" is checked`, async function() {
|
||||
// Log in as a new user who hasn't seen any tips yet.
|
||||
session = await gu.session().user('user2').login({showTips: true});
|
||||
docId = await session.tempNewDoc(cleanup, 'BehavioralPromptsDontShowTips');
|
||||
await gu.loadDoc(`/doc/${docId}`);
|
||||
|
||||
// Check "Don't show tips" in the Reference Columns tip and dismiss it.
|
||||
await gu.setType(/Reference$/);
|
||||
await driver.findWait('.test-behavioral-prompt-dont-show-tips', 1000).click();
|
||||
await gu.dismissBehavioralPrompts();
|
||||
|
||||
// Now visit Raw Data and check that its tip isn't shown.
|
||||
await driver.find('.test-tools-raw').click();
|
||||
await assertPromptTitle(null);
|
||||
});
|
||||
});
|
||||
|
||||
async function assertPromptTitle(title: string | null) {
|
||||
if (title === null) {
|
||||
await gu.waitToPass(async () => {
|
||||
assert.equal(await driver.find('.test-behavioral-prompt').isPresent(), false);
|
||||
});
|
||||
} else {
|
||||
await gu.waitToPass(async () => {
|
||||
assert.equal(await driver.find('.test-behavioral-prompt-title').getText(), title);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,8 @@ export const listDocs = homeUtil.listDocs.bind(homeUtil);
|
||||
export const createHomeApi = homeUtil.createHomeApi.bind(homeUtil);
|
||||
export const simulateLogin = homeUtil.simulateLogin.bind(homeUtil);
|
||||
export const removeLogin = homeUtil.removeLogin.bind(homeUtil);
|
||||
export const enableTips = homeUtil.enableTips.bind(homeUtil);
|
||||
export const disableTips = homeUtil.disableTips.bind(homeUtil);
|
||||
export const setValue = homeUtil.setValue.bind(homeUtil);
|
||||
export const isOnLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);
|
||||
export const isOnGristLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);
|
||||
@@ -1008,9 +1010,19 @@ export async function addNewTable(name?: string) {
|
||||
|
||||
export interface PageWidgetPickerOptions {
|
||||
tableName?: string;
|
||||
selectBy?: RegExp|string; // Optional pattern of SELECT BY option to pick.
|
||||
summarize?: (RegExp|string)[]; // Optional list of patterns to match Group By columns.
|
||||
dontAdd?: boolean; // If true, configure the widget selection without actually adding to the page
|
||||
/** Optional pattern of SELECT BY option to pick. */
|
||||
selectBy?: RegExp|string;
|
||||
/** Optional list of patterns to match Group By columns. */
|
||||
summarize?: (RegExp|string)[];
|
||||
/** If true, configure the widget selection without actually adding to the page. */
|
||||
dontAdd?: boolean;
|
||||
/**
|
||||
* If true, dismiss any tooltips that are shown.
|
||||
*
|
||||
* TODO: Only needed by one test. Can be removed once a fix has landed for the bug
|
||||
* where user-level preferences aren't loaded when the session's org is null.
|
||||
*/
|
||||
dismissTips?: boolean;
|
||||
}
|
||||
|
||||
// Add a new page using the 'Add New' menu and wait for the new page to be shown.
|
||||
@@ -1051,11 +1063,15 @@ export async function openAddWidgetToPage() {
|
||||
export async function selectWidget(
|
||||
typeRe: RegExp|string,
|
||||
tableRe: RegExp|string = '',
|
||||
options: PageWidgetPickerOptions = {}) {
|
||||
options: PageWidgetPickerOptions = {}
|
||||
) {
|
||||
if (options.dismissTips) { await dismissBehavioralPrompts(); }
|
||||
|
||||
// select right type
|
||||
await driver.findContent('.test-wselect-type', typeRe).doClick();
|
||||
|
||||
if (options.dismissTips) { await dismissBehavioralPrompts(); }
|
||||
|
||||
if (tableRe) {
|
||||
const tableEl = driver.findContent('.test-wselect-table', tableRe);
|
||||
|
||||
@@ -1067,6 +1083,8 @@ export async function selectWidget(
|
||||
// let's select table
|
||||
await tableEl.click();
|
||||
|
||||
if (options.dismissTips) { await dismissBehavioralPrompts(); }
|
||||
|
||||
const pivotEl = tableEl.find('.test-wselect-pivot');
|
||||
if (await pivotEl.isPresent()) {
|
||||
await toggleSelectable(pivotEl, Boolean(options.summarize));
|
||||
@@ -1207,10 +1225,9 @@ export async function renameColumn(col: IColHeader, newName: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a table using RAW data view. Returns a current url.
|
||||
* Removes a table using RAW data view.
|
||||
*/
|
||||
export async function removeTable(tableId: string, goBack: boolean = false) {
|
||||
const back = await driver.getCurrentUrl();
|
||||
export async function removeTable(tableId: string) {
|
||||
await driver.find(".test-tools-raw").click();
|
||||
const tableIdList = await driver.findAll('.test-raw-data-table-id', e => e.getText());
|
||||
const tableIndex = tableIdList.indexOf(tableId);
|
||||
@@ -1221,11 +1238,6 @@ export async function removeTable(tableId: string, goBack: boolean = false) {
|
||||
await driver.find(".test-raw-data-menu-remove").click();
|
||||
await driver.find(".test-modal-confirm").click();
|
||||
await waitForServer();
|
||||
if (goBack) {
|
||||
await driver.get(back);
|
||||
await waitAppFocus();
|
||||
}
|
||||
return back;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1526,14 +1538,18 @@ export async function deleteColumn(col: IColHeader|string) {
|
||||
/**
|
||||
* Sets the type of the currently selected field to value.
|
||||
*/
|
||||
export async function setType(type: RegExp|string, options: {skipWait?: boolean, apply?: boolean} = {}) {
|
||||
export async function setType(
|
||||
type: RegExp|string,
|
||||
options: {skipWait?: boolean, apply?: boolean} = {}
|
||||
) {
|
||||
const {skipWait, apply} = options;
|
||||
await toggleSidePanel('right', 'open');
|
||||
await driver.find('.test-right-tab-field').click();
|
||||
await driver.find('.test-fbuilder-type-select').click();
|
||||
type = typeof type === 'string' ? exactMatch(type) : type;
|
||||
await driver.findContentWait('.test-select-menu .test-select-row', type, 500).click();
|
||||
if (!options.skipWait || options.apply) { await waitForServer(); }
|
||||
if (options.apply) {
|
||||
if (!skipWait || apply) { await waitForServer(); }
|
||||
if (apply) {
|
||||
await driver.findWait('.test-type-transform-apply', 1000).click();
|
||||
await waitForServer();
|
||||
}
|
||||
@@ -1912,13 +1928,21 @@ export class Session {
|
||||
public async login(options?: {loginMethod?: UserProfile['loginMethod'],
|
||||
freshAccount?: boolean,
|
||||
isFirstLogin?: boolean,
|
||||
showTips?: boolean,
|
||||
retainExistingLogin?: boolean}) {
|
||||
// Optimize testing a little bit, so if we are already logged in as the expected
|
||||
// user on the expected org, and there are no options set, we can just continue.
|
||||
if (!options && await this.isLoggedInCorrectly()) { return this; }
|
||||
if (!options?.retainExistingLogin) {
|
||||
await removeLogin();
|
||||
if (this.settings.email === 'anon@getgrist.com') { return this; }
|
||||
if (this.settings.email === 'anon@getgrist.com') {
|
||||
if (options?.showTips) {
|
||||
await enableTips(this.settings.email);
|
||||
} else {
|
||||
await disableTips(this.settings.email);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
await server.simulateLogin(this.settings.name, this.settings.email, this.settings.orgDomain,
|
||||
{isFirstLogin: false, cacheCredentials: true, ...options});
|
||||
@@ -2701,6 +2725,39 @@ export async function refreshDismiss() {
|
||||
await waitForDocToLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses all behavioral prompts that are present.
|
||||
*/
|
||||
export async function dismissBehavioralPrompts() {
|
||||
let i = 0;
|
||||
const max = 10;
|
||||
|
||||
// Keep dismissing prompts until there are no more, up to a maximum of 10 times.
|
||||
while (i < max && await driver.find('.test-behavioral-prompt').isPresent()) {
|
||||
await driver.find('.test-behavioral-prompt-dismiss').click();
|
||||
await waitForServer();
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses all card popups that are present.
|
||||
*
|
||||
* Optionally takes a `waitForServerTimeoutMs`, which may be null to skip waiting
|
||||
* after closing each popup.
|
||||
*/
|
||||
export async function dismissCardPopups(waitForServerTimeoutMs: number | null = 2000) {
|
||||
let i = 0;
|
||||
const max = 10;
|
||||
|
||||
// Keep dismissing popups until there are no more, up to a maximum of 10 times.
|
||||
while (i < max && await driver.find('.test-popup-card').isPresent()) {
|
||||
await driver.find('.test-popup-close-button').click();
|
||||
if (waitForServerTimeoutMs) { await waitForServer(waitForServerTimeoutMs); }
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms that anchor link was used for navigation.
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as path from 'path';
|
||||
import {WebDriver} from 'selenium-webdriver';
|
||||
|
||||
import {UserProfile} from 'app/common/LoginSessionAPI';
|
||||
import {BehavioralPrompt, UserPrefs, WelcomePopup} from 'app/common/Prefs';
|
||||
import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
|
||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||
import log from 'app/server/lib/log';
|
||||
@@ -25,6 +26,29 @@ export interface Server {
|
||||
isExternalServer(): boolean;
|
||||
}
|
||||
|
||||
const ALL_TIPS_ENABLED = {
|
||||
behavioralPrompts: {
|
||||
dontShowTips: false,
|
||||
dismissedTips: [],
|
||||
},
|
||||
dismissedWelcomePopups: [],
|
||||
};
|
||||
|
||||
const ALL_TIPS_DISABLED = {
|
||||
behavioralPrompts: {
|
||||
dontShowTips: true,
|
||||
dismissedTips: BehavioralPrompt.values,
|
||||
},
|
||||
dismissedWelcomePopups: WelcomePopup.values.map(id => {
|
||||
return {
|
||||
id,
|
||||
lastDismissedAt: 0,
|
||||
nextAppearanceAt: null,
|
||||
timesDismissed: 1,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
export class HomeUtil {
|
||||
// Cache api keys of test users. It is often convenient to have various instances
|
||||
// of the home api available while making browser tests.
|
||||
@@ -54,9 +78,13 @@ export class HomeUtil {
|
||||
freshAccount?: boolean,
|
||||
isFirstLogin?: boolean,
|
||||
showGristTour?: boolean,
|
||||
showTips?: boolean,
|
||||
cacheCredentials?: boolean,
|
||||
} = {}) {
|
||||
const {loginMethod, isFirstLogin} = defaults(options, {loginMethod: 'Email + Password'});
|
||||
const {loginMethod, isFirstLogin, showTips} = defaults(options, {
|
||||
loginMethod: 'Email + Password',
|
||||
showTips: false,
|
||||
});
|
||||
const showGristTour = options.showGristTour ?? (options.freshAccount ?? isFirstLogin);
|
||||
|
||||
// For regular tests, we can log in through a testing hook.
|
||||
@@ -64,6 +92,11 @@ export class HomeUtil {
|
||||
if (options.freshAccount) { await this._deleteUserByEmail(email); }
|
||||
if (isFirstLogin !== undefined) { await this._setFirstLogin(email, isFirstLogin); }
|
||||
if (showGristTour !== undefined) { await this._initShowGristTour(email, showGristTour); }
|
||||
if (showTips) {
|
||||
await this.enableTips(email);
|
||||
} else {
|
||||
await this.disableTips(email);
|
||||
}
|
||||
// TestingHooks communicates via JSON, so it's impossible to send an `undefined` value for org
|
||||
// through it. Using the empty string happens to work though.
|
||||
const testingHooks = await this.server.getTestingHooks();
|
||||
@@ -122,6 +155,14 @@ export class HomeUtil {
|
||||
}
|
||||
}
|
||||
|
||||
public async enableTips(email: string) {
|
||||
await this._toggleTips(true, email);
|
||||
}
|
||||
|
||||
public async disableTips(email: string) {
|
||||
await this._toggleTips(false, email);
|
||||
}
|
||||
|
||||
// Check if the url looks like a welcome page. The check is weak, but good enough
|
||||
// for testing.
|
||||
public async isWelcomePage() {
|
||||
@@ -396,4 +437,30 @@ export class HomeUtil {
|
||||
newFormData: () => new FormData() as any, // form-data isn't quite type compatible
|
||||
logger: log});
|
||||
}
|
||||
|
||||
private async _toggleTips(enabled: boolean, email: string) {
|
||||
if (this.server.isExternalServer()) { throw new Error('not supported'); }
|
||||
|
||||
const dbManager = await this.server.getDatabase();
|
||||
const user = await dbManager.getUserByLogin(email);
|
||||
if (!user) { return; }
|
||||
|
||||
if (user.personalOrg) {
|
||||
const org = await dbManager.getOrg({userId: user.id}, user.personalOrg.id);
|
||||
const userPrefs = (org.data as any)?.userPrefs ?? {};
|
||||
const newUserPrefs: UserPrefs = {
|
||||
...userPrefs,
|
||||
...(enabled ? ALL_TIPS_ENABLED : ALL_TIPS_DISABLED),
|
||||
};
|
||||
await dbManager.updateOrg({userId: user.id}, user.personalOrg.id, {userPrefs: newUserPrefs});
|
||||
} else {
|
||||
await this.driver.executeScript(`
|
||||
const userPrefs = JSON.parse(localStorage.getItem('userPrefs:u=${user.id}') || '{}');
|
||||
localStorage.setItem('userPrefs:u=${user.id}', JSON.stringify({
|
||||
...userPrefs,
|
||||
...${JSON.stringify(enabled ? ALL_TIPS_ENABLED : ALL_TIPS_DISABLED)},
|
||||
}));
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user