(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:
George Gevoian
2022-12-19 21:06:39 -05:00
parent fa75c93d67
commit e52e15591d
41 changed files with 1236 additions and 126 deletions

View 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);
});
}
}

View File

@@ -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.
*/

View File

@@ -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)},
}));
`);
}
}
}