gristlabs_grist-core/test/nbrowser/DocTutorial.ts
George Gevoian 4740f1f933 (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
2024-07-23 11:49:23 -04:00

612 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {DocCreationInfo} from 'app/common/DocListAPI';
import {UserAPI} from 'app/common/UserAPI';
import {assert, driver, Key} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import {EnvironmentSnapshot} from 'test/server/testUtils';
describe('DocTutorial', function () {
this.timeout(60000);
gu.bigScreen();
let doc: DocCreationInfo;
let api: UserAPI;
let ownerSession: gu.Session;
let editorSession: gu.Session;
let viewerSession: gu.Session;
let oldEnv: EnvironmentSnapshot;
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();
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',
}});
});
after(async function () {
oldEnv.restore();
await server.restart();
});
describe('when logged out', function () {
before(async () => {
viewerSession = await gu.session().anon.login();
});
it('shows a tutorial card', async function() {
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%');
});
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 driver.find('.test-dm-basic-tutorial').click();
await gu.checkLoginPage();
});
});
describe('when logged in', function () {
let forkUrl: string;
before(async () => {
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() {
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 driver.find('.test-dm-basic-tutorial').click();
await driver.wait(async () => {
forkUrl = await driver.getCurrentUrl();
return /~/.test(forkUrl);
});
});
it('shows a popup containing slides generated from the GristDocTutorial table', async function() {
assert.isTrue(await driver.findWait('.test-doc-tutorial-popup', 2000).isDisplayed());
assert.equal(await driver.find('.test-floating-popup-header').getText(), 'DocTutorial');
assert.equal(
await driver.findWait('.test-doc-tutorial-popup h1', 2000).getText(),
'Intro'
);
assert.equal(
await driver.find('.test-doc-tutorial-popup p').getText(),
'Welcome to the Grist Basics tutorial. We will cover the most important Grist '
+ 'concepts and features. Lets get started.'
);
});
it('can be moved around and minimized', async function() {
// Get the initial position of the popup.
const initialDims = await driver.find('.test-floating-popup-window').getRect();
// Get the move handle.
const mover = await driver.find('.test-floating-popup-move-handle');
const moverInitial = await mover.getRect();
const move = async (pos: {x?: number, y?: number}) => driver.withActions((actions) => actions
.move({origin: driver.find('.test-floating-popup-move-handle')})
.press()
.move({origin: driver.find('.test-floating-popup-move-handle'), ...pos})
.release()
);
const resize = async (pos: {x?: number, y?: number}) => driver.withActions((actions) => actions
.move({origin: driver.find('.test-floating-popup-resize-handle')})
.press()
.move({origin: driver.find('.test-floating-popup-resize-handle'), ...pos})
.release()
);
// Move it a little bit down.
await move({y: 100, x: -10});
// Check it is moved but not shrinked.
let dims = await driver.find('.test-floating-popup-window').getRect();
assert.equal(dims.height, initialDims.height);
// And moving down.
assert.equal(dims.y, initialDims.y + 100);
assert.equal(dims.x, initialDims.x - 10);
// Now move it a little up and test it doesn't grow.
await move({y: -100});
dims = await driver.find('.test-floating-popup-window').getRect();
assert.equal(dims.height, initialDims.height);
assert.equal(dims.y, initialDims.y);
// Resize it in steps.
await resize({y: 10});
await resize({y: 10});
await resize({y: 10});
await resize({y: 10});
await resize({y: 10});
await resize({y: 50});
dims = await driver.find('.test-floating-popup-window').getRect();
assert.equal(dims.height, initialDims.height - 100);
assert.equal(dims.y, initialDims.y + 100);
// Resize back (in steps, to simulate user actions)
await resize({y: -20});
await resize({y: -20});
await resize({y: -20});
await resize({y: -40});
dims = await driver.find('.test-floating-popup-window').getRect();
assert.equal(dims.height, initialDims.height);
assert.equal(dims.y, initialDims.y);
// Now resize it beyond the maximum size and check it doesn't move.
await resize({y: -10});
dims = await driver.find('.test-floating-popup-window').getRect();
assert.equal(dims.height, initialDims.height);
assert.equal(dims.y, initialDims.y);
// Now resize it to the minimum size.
await resize({y: 700});
dims = await driver.find('.test-floating-popup-window').getRect();
assert.equal(dims.height, 300);
// Get window inner size.
const windowHeight: any = await driver.executeScript('return window.innerHeight');
const windowWidth: any = await driver.executeScript('return window.innerWidth');
assert.equal(dims.y + dims.height, windowHeight - 16);
// Now we'll test moving the window outside the viewport. Selenium throws when
// the mouse exceeds the bounds of the viewport, so we can't test every scenario.
// Start by moving the window as low as possible.
await move({y: windowHeight - dims.y - 32});
// Make sure it is still visible.
dims = await driver.find('.test-floating-popup-window').getRect();
assert.equal(dims.y, windowHeight - 32); // 32px is a header size
assert.isBelow(dims.x, windowWidth - (32 * 4) + 1); // 120px is the right overflow value.
// Now move it to the right as far as possible.
await move({x: windowWidth - dims.x - 10});
// Make sure it is still visible.
dims = await driver.find('.test-floating-popup-window').getRect();
assert.equal(dims.y, windowHeight - 32);
assert.equal(dims.x, windowWidth - 32 * 4);
// Now move it to the left as far as possible.
await move({x: -windowWidth + 128});
// Make sure it is still visible.
dims = await driver.find('.test-floating-popup-window').getRect();
assert.equal(dims.x, 0);
assert.isAbove(dims.x + dims.width, 30);
// Now move it to the top as far as possible.
// Move it a little right, so that we don't end up on the logo. Driver is clicking logo sometimes.
await move({y: -windowHeight + 16, x: 100});
// Make sure it is still visible.
dims = await driver.find('.test-floating-popup-window').getRect();
assert.equal(dims.y, 16);
assert.equal(dims.x, 100);
// Move back where it was.
const moverNow = await driver.find('.test-floating-popup-move-handle').getRect();
await move({x: moverInitial.x - moverNow.x});
// And restore the size by double clicking the resizer.
await driver.withActions((a) => a.doubleClick(driver.find('.test-floating-popup-resize-handle')));
});
it('is visible on all pages', async function() {
for (const page of ['access-rules', 'raw', 'code', 'settings']) {
await driver.find(`.test-tools-${page}`).click();
if (['access-rules', 'code'].includes(page)) { await gu.waitForServer(); }
assert.isTrue(await driver.find('.test-doc-tutorial-popup').isDisplayed());
}
});
it('does not break navigation via browser history', async function() {
// Navigating via browser history was partially broken at one point; if you
// started a tutorial, and wanted to leave via the browser's back button, you
// couldn't.
for (const page of ['code', 'data', 'acl']) {
await driver.navigate().back();
const currentUrl = await driver.getCurrentUrl();
assert.match(currentUrl, new RegExp(`/p/${page}$`));
}
await driver.navigate().back();
await driver.navigate().back();
await driver.findWait('.test-dm-doclist', 2000);
await driver.navigate().forward();
await gu.waitForDocToLoad();
assert.isTrue(await driver.findWait('.test-doc-tutorial-popup', 2000).isDisplayed());
});
it('shows the GristDocTutorial page and table to editors', async function() {
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'GristDocTutorial']);
await driver.find('.test-tools-raw').click();
await driver.findWait('.test-raw-data-list', 1000);
await gu.waitForServer();
assert.isTrue(await driver.findContent('.test-raw-data-table-id',
/GristDocTutorial/).isPresent());
});
it('does not show the GristDocTutorial page or table to non-editors', async function() {
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();
await driver.findWait('.test-raw-data-list', 1000);
await gu.waitForServer();
assert.isFalse(await driver.findContent('.test-raw-data-table-id',
/GristDocTutorial/).isPresent());
});
it('does not show behavioral tips', async function() {
await gu.openPage('Page 1');
await gu.openAddWidgetToPage();
assert.equal(await driver.find('.test-behavioral-prompt').isPresent(), false);
await gu.sendKeys(Key.ESCAPE);
});
it('only allows users access to their own forks', async function() {
await driver.navigate().to(forkUrl);
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
await viewerSession.loadDoc(`/doc/${doc.id}`);
let otherForkUrl: string;
await driver.wait(async () => {
otherForkUrl = await driver.getCurrentUrl();
return /~/.test(forkUrl);
});
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);
await gu.waitForDocToLoad();
});
it('supports navigating to the next or previous slide', async function() {
await driver.findWait('.test-doc-tutorial-popup', 2000);
assert.isTrue(await driver.findWait('.test-doc-tutorial-popup-next', 2000).isDisplayed());
assert.isFalse(await driver.find('.test-doc-tutorial-popup-previous').isDisplayed());
await driver.find('.test-doc-tutorial-popup-next').click();
assert.equal(
await driver.find('.test-doc-tutorial-popup h1').getText(),
'Pages'
);
assert.equal(
await driver.find('.test-doc-tutorial-popup h1 + p').getText(),
'On the left-side panel is a list of pages which are views of your data. Right'
+ ' now, there are two pages, Page 1 and Page 2. You are looking at Page 1.'
);
assert.isTrue(await driver.find('.test-doc-tutorial-popup-next').isDisplayed());
assert.isTrue(await driver.find('.test-doc-tutorial-popup-previous').isDisplayed());
await driver.find('.test-doc-tutorial-popup-previous').click();
assert.equal(
await driver.find('.test-doc-tutorial-popup h1').getText(),
'Intro'
);
assert.equal(
await driver.find('.test-doc-tutorial-popup p').getText(),
'Welcome to the Grist Basics tutorial. We will cover the most important Grist '
+ 'concepts and features. Lets get started.'
);
assert.isTrue(await driver.find('.test-doc-tutorial-popup-next').isDisplayed());
assert.isFalse(await driver.find('.test-doc-tutorial-popup-previous').isDisplayed());
});
it('supports navigating to a specific slide', async function() {
const slide3 = await driver.find('.test-doc-tutorial-popup-slide-3');
await slide3.mouseMove();
await gu.waitToPass(
async () => assert.isTrue(await driver.find('.test-tooltip').isDisplayed()),
500
);
assert.equal(await driver.find('.test-tooltip').getText(), 'Adding Columns and Rows');
await slide3.click();
await driver.find('.test-doc-tutorial-popup-slide-3').click();
assert.equal(
await driver.find('.test-doc-tutorial-popup h1').getText(),
'Adding Columns and Rows'
);
assert.equal(
await driver.find('.test-doc-tutorial-popup p').getText(),
"You can add new columns to your table by clicking the + icon"
+ ' to the far right of your column headers.'
);
const slide1 = await driver.find('.test-doc-tutorial-popup-slide-1');
await slide1.mouseMove();
await gu.waitToPass(
async () => assert.isTrue(await driver.find('.test-tooltip').isDisplayed()),
500
);
assert.equal(await driver.find('.test-tooltip').getText(), 'Intro');
await slide1.click();
assert.equal(
await driver.find('.test-doc-tutorial-popup h1').getText(),
'Intro'
);
assert.equal(
await driver.find('.test-doc-tutorial-popup p').getText(),
'Welcome to the Grist Basics tutorial. We will cover the most important Grist '
+ 'concepts and features. Lets get started.'
);
});
it('can open images in a lightbox', async function() {
await driver.find('.test-doc-tutorial-popup img').click();
assert.isTrue(await driver.find('.test-doc-tutorial-lightbox').isDisplayed());
assert.equal(
await driver.find('.test-doc-tutorial-lightbox-image').getAttribute('src'),
'https://www.getgrist.com/wp-content/uploads/2023/03/Row-1-Intro.png'
);
await driver.find('.test-doc-tutorial-lightbox-close').click();
assert.isFalse(await driver.find('.test-doc-tutorial-lightbox').isPresent());
});
it('can be minimized and maximized by clicking a button in the header', async function() {
await driver.find('.test-floating-popup-minimize-maximize').click();
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
assert.isFalse(await driver.find('.test-doc-tutorial-popup-body').isDisplayed());
assert.isFalse(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed());
await driver.find('.test-floating-popup-minimize-maximize').click();
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
assert.isTrue(await driver.find('.test-doc-tutorial-popup-body').isDisplayed());
assert.isTrue(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed());
});
it('can be minimized and maximized by double clicking the header', async function() {
await driver.withActions(a => a.doubleClick(driver.find('.test-floating-popup-title')));
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
assert.isFalse(await driver.find('.test-doc-tutorial-popup-body').isDisplayed());
assert.isFalse(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed());
await driver.withActions(a => a.doubleClick(driver.find('.test-floating-popup-title')));
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
assert.isTrue(await driver.find('.test-doc-tutorial-popup-body').isDisplayed());
assert.isTrue(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed());
});
it('does not play an easter egg when opening an anchor link encoded with rr', async function() {
await gu.getCell({rowNum: 1, col: 0}).click();
const link = await gu.getAnchor();
const easterEggLink = link.replace('.r1', '.rr1');
await driver.get(easterEggLink);
await gu.waitForAnchor();
assert.isFalse(await driver.find('.test-behavioral-prompt').isPresent());
await gu.assertIsRickRowing(false);
});
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 when updating tutorial progress.
await driver.sleep(1000 + 250);
await gu.waitForServer();
await driver.navigate().refresh();
await gu.waitForDocToLoad();
await driver.findWait('.test-doc-tutorial-popup', 2000);
assert.equal(
await driver.findWait('.test-doc-tutorial-popup h1', 2000).getText(),
'Adding Columns and Rows'
);
assert.equal(
await driver.find('.test-doc-tutorial-popup p').getText(),
"You can add new columns to your table by clicking the + icon"
+ ' to the far right of your column headers.'
);
});
it('always opens the same fork whenever the document is opened', async function() {
assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Zane Rails']);
await gu.getCell(0, 1).click();
await gu.sendKeys('Redacted', Key.ENTER);
await gu.waitForServer();
await editorSession.loadDoc(`/doc/${doc.id}`);
let currentUrl: string;
await driver.wait(async () => {
currentUrl = await driver.getCurrentUrl();
return /~/.test(forkUrl);
});
assert.equal(currentUrl!, forkUrl);
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();
await gu.waitForServer();
assert.isTrue(await driver.findContentWait('.test-raw-data-table-id',
/GristDocTutorial/, 2000).isPresent());
assert.isFalse(await driver.find('.test-doc-tutorial-popup').isPresent());
});
it('can restart tutorials', async function() {
// 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();
// 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);
// Check that the new table isn't in the fork.
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'GristDocTutorial']);
assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Redacted']);
// Restart the tutorial.
await driver.find('.test-doc-tutorial-popup-restart').click();
await driver.find('.test-modal-confirm').click();
await gu.waitForServer();
await driver.findWait('.test-doc-tutorial-popup', 2000);
// Check that progress was reset.
assert.equal(
await driver.findWait('.test-doc-tutorial-popup h1', 2000).getText(),
'Intro'
);
assert.equal(
await driver.find('.test-doc-tutorial-popup p').getText(),
'Welcome to the Grist Basics tutorial. We will cover the most important Grist '
+ 'concepts and features. Lets get started.'
);
// Check that edits were reset.
assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Zane Rails']);
// 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', 'Table1']);
});
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();
await gu.sendKeys(
'# Intro',
Key.chord(Key.SHIFT, Key.ENTER),
Key.chord(Key.SHIFT, Key.ENTER),
'Welcome to the Grist Basics tutorial V2.',
Key.ENTER,
);
await gu.waitForServer();
// Check that the update is immediately reflected in the tutorial popup.
assert.equal(
await driver.findWait('.test-doc-tutorial-popup p', 2000).getText(),
'Welcome to the Grist Basics tutorial V2.'
);
// Replace the original via the Share menu.
await driver.find('.test-tb-share').click();
await driver.find('.test-replace-original').click();
await driver.findWait('.test-modal-confirm', 3000).click();
await gu.waitForServer();
// Switch to another user and restart the tutorial.
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();
await gu.waitForServer();
await driver.findWait('.test-doc-tutorial-popup', 2000);
// Check that the changes we made earlier are included.
assert.equal(
await driver.findWait('.test-doc-tutorial-popup p', 2000).getText(),
'Welcome to the Grist Basics tutorial V2.'
);
});
it('redirects to the last visited site when finished', async function() {
const otherSiteSession = await gu.session().personalSite.user('user1').addLogin();
await otherSiteSession.loadDocMenu('/');
await ownerSession.loadDoc(`/doc/${doc.id}`);
await driver.findWait('.test-doc-tutorial-popup-slide-13', 2000).click();
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().customTeamSite('templates').user('support').login();
await ownerSession.loadDoc(`/doc/${doc.id}`);
});
afterEach(() => gu.checkForErrors());
it('shows the GristDocTutorial page and table', async function() {
assert.deepEqual(await gu.getPageNames(),
['Page 1', 'Page 2', 'GristDocTutorial', 'Table1']);
await gu.openPage('GristDocTutorial');
assert.deepEqual(
await gu.getVisibleGridCells({cols: [1, 2], rowNums: [1]}),
[
'# Intro\n\nWelcome to the Grist Basics tutorial V2.',
'',
]
);
await driver.find('.test-tools-raw').click();
await gu.waitForServer();
assert.isTrue(await driver.findContentWait('.test-raw-data-table-id',
/GristDocTutorial/, 2000).isPresent());
});
it('does not show the tutorial popup', async function() {
assert.isFalse(await driver.find('.test-doc-tutorial-popup').isPresent());
});
});
});