(core) Update onboarding flow

Summary:
A new onboarding page is now shown to all new users visiting the doc
menu for the first time. Tutorial cards on the doc menu have been
replaced with a new version that tracks completion progress, alongside
a new card that opens the orientation video.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4296
This commit is contained in:
George Gevoian
2024-07-22 11:10:57 -04:00
parent 3fd8719d8a
commit 4740f1f933
40 changed files with 1462 additions and 706 deletions

View File

@@ -13,22 +13,27 @@ describe('DocTutorial', function () {
let doc: DocCreationInfo;
let api: UserAPI;
let ownerSession: gu.Session;
let editorSession: gu.Session;
let viewerSession: gu.Session;
let oldEnv: EnvironmentSnapshot;
const cleanup = setupTestSuite({team: true});
const cleanup = setupTestSuite({samples: true, team: true});
before(async () => {
ownerSession = await gu.session().customTeamSite('templates').user('support').login();
doc = await ownerSession.tempDoc(cleanup, 'DocTutorial.grist', {load: false});
oldEnv = new EnvironmentSnapshot();
process.env.GRIST_UI_FEATURES = 'tutorials';
process.env.GRIST_TEMPLATE_ORG = 'templates';
process.env.GRIST_ONBOARDING_TUTORIAL_DOC_ID = doc.id;
await server.restart();
ownerSession = await gu.session().teamSite.user('support').login();
doc = await ownerSession.tempDoc(cleanup, 'DocTutorial.grist');
api = ownerSession.createHomeApi();
await api.updateDoc(doc.id, {type: 'tutorial'});
await api.updateDocPermissions(doc.id, {users: {
'anon@getgrist.com': 'viewers',
'everyone@getgrist.com': 'viewers',
[gu.translateUser('user1').email]: 'editors',
}});
});
@@ -43,20 +48,25 @@ describe('DocTutorial', function () {
});
it('shows a tutorial card', async function() {
await viewerSession.loadRelPath('/');
await gu.waitForDocMenuToLoad();
await gu.skipWelcomeQuestions();
await viewerSession.loadDocMenu('/');
assert.isTrue(await driver.find('.test-onboarding-tutorial-card').isDisplayed());
assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '0%');
});
assert.isTrue(await driver.find('.test-tutorial-card-content').isDisplayed());
// Can dismiss it.
await driver.find('.test-tutorial-card-close').click();
assert.isFalse((await driver.findAll('.test-tutorial-card-content')).length > 0);
// When dismissed, we can see link in the menu.
it('can dismiss tutorial card', async function() {
await driver.find('.test-onboarding-dismiss-cards').click();
assert.isFalse(await driver.find('.test-onboarding-tutorial-card').isPresent());
await driver.navigate().refresh();
await gu.waitForDocMenuToLoad();
assert.isFalse(await driver.find('.test-onboarding-tutorial-card').isPresent());
});
it('shows a link to tutorial', async function() {
assert.isTrue(await driver.find('.test-dm-basic-tutorial').isDisplayed());
});
it('redirects user to log in', async function() {
await viewerSession.loadDoc(`/doc/${doc.id}`, {wait: false});
await driver.find('.test-dm-basic-tutorial').click();
await gu.checkLoginPage();
});
});
@@ -65,41 +75,24 @@ describe('DocTutorial', function () {
let forkUrl: string;
before(async () => {
ownerSession = await gu.session().teamSite.user('user1').login({showTips: true});
editorSession = await gu.session().customTeamSite('templates').user('user1').login({showTips: true});
await editorSession.loadDocMenu('/');
await driver.executeScript('resetDismissedPopups();');
await gu.waitForServer();
});
afterEach(() => gu.checkForErrors());
it('shows a tutorial card', async function() {
await ownerSession.loadRelPath('/');
await gu.waitForDocMenuToLoad();
await gu.skipWelcomeQuestions();
// Make sure we have clean start.
await driver.executeScript('resetDismissedPopups();');
await gu.waitForServer();
await driver.navigate().refresh();
await gu.waitForDocMenuToLoad();
// Make sure we see the card.
assert.isTrue(await driver.find('.test-tutorial-card-content').isDisplayed());
// And can dismiss it.
await driver.find('.test-tutorial-card-close').click();
assert.isFalse((await driver.findAll('.test-tutorial-card-content')).length > 0);
// When dismissed, we can see link in the menu.
assert.isTrue(await driver.find('.test-dm-basic-tutorial').isDisplayed());
// Prefs are preserved after reload.
await driver.navigate().refresh();
await gu.waitForDocMenuToLoad();
assert.isFalse((await driver.findAll('.test-tutorial-card-content')).length > 0);
assert.isTrue(await driver.find('.test-dm-basic-tutorial').isDisplayed());
assert.isTrue(await driver.find('.test-onboarding-tutorial-card').isDisplayed());
await gu.waitToPass(async () =>
assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '0%'),
2000
);
});
it('creates a fork the first time the document is opened', async function() {
await ownerSession.loadDoc(`/doc/${doc.id}`);
await driver.find('.test-dm-basic-tutorial').click();
await driver.wait(async () => {
forkUrl = await driver.getCurrentUrl();
return /~/.test(forkUrl);
@@ -274,7 +267,7 @@ describe('DocTutorial', function () {
});
it('does not show the GristDocTutorial page or table to non-editors', async function() {
viewerSession = await gu.session().teamSite.user('user2').login();
viewerSession = await gu.session().customTeamSite('templates').user('user2').login();
await viewerSession.loadDoc(`/doc/${doc.id}`);
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2']);
await driver.find('.test-tools-raw').click();
@@ -300,7 +293,7 @@ describe('DocTutorial', function () {
otherForkUrl = await driver.getCurrentUrl();
return /~/.test(forkUrl);
});
ownerSession = await gu.session().teamSite.user('user1').login();
editorSession = await gu.session().customTeamSite('templates').user('user1').login();
await driver.navigate().to(otherForkUrl!);
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
await driver.navigate().to(forkUrl);
@@ -424,7 +417,7 @@ describe('DocTutorial', function () {
it('remembers the last slide the user had open', async function() {
await driver.find('.test-doc-tutorial-popup-slide-3').click();
// There's a 1000ms debounce in place for updates to the last slide.
// There's a 1000ms debounce in place when updating tutorial progress.
await driver.sleep(1000 + 250);
await gu.waitForServer();
await driver.navigate().refresh();
@@ -446,7 +439,7 @@ describe('DocTutorial', function () {
await gu.getCell(0, 1).click();
await gu.sendKeys('Redacted', Key.ENTER);
await gu.waitForServer();
await ownerSession.loadDoc(`/doc/${doc.id}`);
await editorSession.loadDoc(`/doc/${doc.id}`);
let currentUrl: string;
await driver.wait(async () => {
currentUrl = await driver.getCurrentUrl();
@@ -456,7 +449,20 @@ describe('DocTutorial', function () {
assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Redacted']);
});
it('tracks completion percentage', async function() {
await driver.find('.test-doc-tutorial-popup-end-tutorial').click();
await gu.waitForServer();
await gu.waitForDocMenuToLoad();
await gu.waitToPass(async () =>
assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '15%'),
2000
);
await driver.find('.test-dm-basic-tutorial').click();
await gu.waitForDocToLoad();
});
it('skips starting or resuming a tutorial if the open mode is set to default', async function() {
ownerSession = await gu.session().customTeamSite('templates').user('support').login();
await ownerSession.loadDoc(`/doc/${doc.id}/m/default`);
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'GristDocTutorial']);
await driver.find('.test-tools-raw').click();
@@ -467,11 +473,14 @@ describe('DocTutorial', function () {
});
it('can restart tutorials', async function() {
// Simulate that the tutorial has been updated since it was forked.
await api.updateDoc(doc.id, {name: 'DocTutorial V2'});
await api.applyUserActions(doc.id, [['AddTable', 'NewTable', [{id: 'A'}]]]);
// Update the tutorial as the owner.
await driver.find('.test-bc-doc').doClick();
await driver.sendKeys('DocTutorial V2', Key.ENTER);
await gu.waitForServer();
await gu.addNewTable();
// Load the fork of the tutorial.
// Switch back to the editor's fork of the tutorial.
editorSession = await gu.session().customTeamSite('templates').user('user1').login();
await driver.navigate().to(forkUrl);
await gu.waitForDocToLoad();
await driver.findWait('.test-doc-tutorial-popup', 2000);
@@ -503,10 +512,13 @@ describe('DocTutorial', function () {
// Check that changes made to the tutorial since it was last started are included.
assert.equal(await driver.find('.test-doc-tutorial-popup-header').getText(),
'DocTutorial V2');
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'GristDocTutorial', 'NewTable']);
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'GristDocTutorial', 'Table1']);
});
it('allows editors to replace original', async function() {
it('allows owners to replace original', async function() {
ownerSession = await gu.session().customTeamSite('templates').user('support').login();
await ownerSession.loadDoc(`/doc/${doc.id}`);
// Make an edit to one of the tutorial slides.
await gu.openPage('GristDocTutorial');
await gu.getCell(1, 1).click();
@@ -532,7 +544,7 @@ describe('DocTutorial', function () {
await gu.waitForServer();
// Switch to another user and restart the tutorial.
viewerSession = await gu.session().teamSite.user('user2').login();
viewerSession = await gu.session().customTeamSite('templates').user('user2').login();
await viewerSession.loadDoc(`/doc/${doc.id}`);
await driver.findWait('.test-doc-tutorial-popup-restart', 2000).click();
await driver.find('.test-modal-confirm').click();
@@ -554,13 +566,22 @@ describe('DocTutorial', function () {
await driver.find('.test-doc-tutorial-popup-next').click();
await gu.waitForDocMenuToLoad();
assert.match(await driver.getCurrentUrl(), /o\/docs\/$/);
await gu.waitToPass(async () =>
assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '0%'),
2000
);
await ownerSession.loadDocMenu('/');
await gu.waitToPass(async () =>
assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '100%'),
2000
);
});
});
describe('without tutorial flag set', function () {
before(async () => {
await api.updateDoc(doc.id, {type: null});
ownerSession = await gu.session().teamSite.user('user1').login();
ownerSession = await gu.session().customTeamSite('templates').user('support').login();
await ownerSession.loadDoc(`/doc/${doc.id}`);
});
@@ -568,7 +589,7 @@ describe('DocTutorial', function () {
it('shows the GristDocTutorial page and table', async function() {
assert.deepEqual(await gu.getPageNames(),
['Page 1', 'Page 2', 'GristDocTutorial', 'NewTable']);
['Page 1', 'Page 2', 'GristDocTutorial', 'Table1']);
await gu.openPage('GristDocTutorial');
assert.deepEqual(
await gu.getVisibleGridCells({cols: [1, 2], rowNums: [1]}),

View File

@@ -33,6 +33,7 @@ describe('Features', function () {
it('can be disabled with the GRIST_HIDE_UI_ELEMENTS env variable', async function () {
process.env.GRIST_UI_FEATURES = 'helpCenter,tutorials';
process.env.GRIST_HIDE_UI_ELEMENTS = 'templates';
process.env.GRIST_ONBOARDING_TUTORIAL_DOC_ID = 'tutorialDocId';
await server.restart();
await session.loadDocMenu('/');
assert.isTrue(await driver.find('.test-left-feedback').isDisplayed());

View File

@@ -52,8 +52,8 @@ describe('HomeIntro', function() {
freshAccount: true,
});
// Open doc-menu and dismiss the welcome questions popup
await session.loadDocMenu('/', 'skipWelcomeQuestions');
// Open doc-menu and skip onboarding
await session.loadDocMenu('/', 'skipOnboarding');
// Reload the doc-menu and dismiss the coaching call popup
await session.loadDocMenu('/');
@@ -83,7 +83,7 @@ describe('HomeIntro', function() {
await session.resetSite();
// Open doc-menu
await session.loadDocMenu('/', 'skipWelcomeQuestions');
await session.loadDocMenu('/');
// Check message specific to logged-in user and an empty team site.
assert.match(await driver.find('.test-welcome-title').getText(), new RegExp(`Welcome.* ${session.orgName}`));

View File

@@ -826,8 +826,23 @@ export async function loadDoc(relPath: string, wait: boolean = true): Promise<vo
if (wait) { await waitForDocToLoad(); }
}
export async function loadDocMenu(relPath: string, wait: boolean = true): Promise<void> {
/**
* Load a DocMenu on a site.
*
* If loading for a potentially first-time user, you may give 'skipOnboarding' for second
* argument to skip the onboarding flow, if it gets shown.
*/
export async function loadDocMenu(relPath: string, wait: boolean|'skipOnboarding' = true): Promise<void> {
await driver.get(`${server.getHost()}${relPath}`);
if (wait === 'skipOnboarding') {
const first = await Promise.race([
driver.findWait('.test-onboarding-page', 2000),
driver.findWait('.test-dm-doclist', 2000),
]);
if (await first.matches('.test-onboarding-page')) {
await skipOnboarding();
}
}
if (wait) { await waitForDocMenuToLoad(); }
}
@@ -2105,7 +2120,6 @@ export class Session {
freshAccount?: boolean,
isFirstLogin?: boolean,
showTips?: boolean,
skipTutorial?: boolean, // By default true
userName?: string,
email?: string,
retainExistingLogin?: boolean}) {
@@ -2129,11 +2143,6 @@ export class Session {
}
await server.simulateLogin(this.settings.name, this.settings.email, this.settings.orgDomain,
{isFirstLogin: false, cacheCredentials: true, ...options});
if (options?.skipTutorial ?? true) {
await dismissTutorialCard();
}
return this;
}
@@ -2172,17 +2181,20 @@ export class Session {
}
// Load a DocMenu on a site.
// If loading for a potentially first-time user, you may give 'skipWelcomeQuestions' for second
// argument to dismiss the popup with welcome questions, if it gets shown.
public async loadDocMenu(relPath: string, wait: boolean|'skipWelcomeQuestions' = true) {
// If loading for a potentially first-time user, you may give 'skipOnboarding' for second
// argument to skip the onboarding flow, if it gets shown.
public async loadDocMenu(relPath: string, wait: boolean|'skipOnboarding' = true) {
await this.loadRelPath(relPath);
if (wait) { await waitForDocMenuToLoad(); }
if (wait === 'skipWelcomeQuestions') {
// When waitForDocMenuToLoad() returns, welcome questions should also render, so that we
// don't need to wait extra for them.
await skipWelcomeQuestions();
if (wait === 'skipOnboarding') {
const first = await Promise.race([
driver.findWait('.test-onboarding-page', 2000),
driver.findWait('.test-dm-doclist', 2000),
]);
if (await first.matches('.test-onboarding-page')) {
await skipOnboarding();
}
}
if (wait) { await waitForDocMenuToLoad(); }
}
public async loadRelPath(relPath: string) {
@@ -3151,21 +3163,6 @@ export async function getFilterMenuState(): Promise<FilterMenuValue[]> {
}));
}
/**
* Dismisses any tutorial card that might be active.
*/
export async function dismissTutorialCard() {
// If there is something in our way, we can't do it.
if (await driver.find('.test-welcome-questions').isPresent()) {
return;
}
if (await driver.find('.test-tutorial-card-close').isPresent()) {
if (await driver.find('.test-tutorial-card-close').isDisplayed()) {
await driver.find('.test-tutorial-card-close').click();
}
}
}
/**
* Dismisses coaching call if needed.
*/
@@ -3370,11 +3367,14 @@ export async function setRangeFilterBound(minMax: 'min'|'max', value: string|{re
}
}
export async function skipWelcomeQuestions() {
if (await driver.find('.test-welcome-questions').isPresent()) {
await driver.sendKeys(Key.ESCAPE);
assert.equal(await driver.find('.test-welcome-questions').isPresent(), false);
}
/**
* Skips the onboarding page that's shown to users on their first visit to the
* doc menu.
*/
export async function skipOnboarding() {
await driver.findWait('.test-onboarding-page', 2000);
await waitForServer();
await driver.navigate().refresh();
}
/**