(core) Enable auto triggering of Welcome Tour, and various improvements.

Summary:
- Add showGristTour preference, and trigger tour automatically.
- Tour is only triggered for new and anonymous users on a personal org, with
  edit permission.

- Automatically open the right panel at tour start.
- Don't show tours on mobile, since that's not ready (popups are cut off
  and can't be dismissed)
- Cancel previous tour if a new one is somehow started.
- Remove #repeat- trigger hash tags from the URL when the tour starts.
- Ensure Help Center popup is positioned even when left panel is collapsed.
- Polish up the content of the last two cards in the tour.

Test Plan: Added test case for triggering and opening right panel.

Reviewers: alexmojaki, paulfitz

Reviewed By: alexmojaki

Differential Revision: https://phab.getgrist.com/D2955
This commit is contained in:
Dmitry S 2021-07-30 11:16:33 -04:00
parent 73c4efa315
commit 1605e18f66
12 changed files with 120 additions and 35 deletions

View File

@ -29,6 +29,7 @@ import {DocPageModel} from 'app/client/models/DocPageModel';
import {UserError} from 'app/client/models/errors'; import {UserError} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
import {QuerySetManager} from 'app/client/models/QuerySet'; import {QuerySetManager} from 'app/client/models/QuerySet';
import {getUserOrgPrefObs} from "app/client/models/UserPrefs";
import {App} from 'app/client/ui/App'; import {App} from 'app/client/ui/App';
import {DocHistory} from 'app/client/ui/DocHistory'; import {DocHistory} from 'app/client/ui/DocHistory';
import {showDocSettingsModal} from 'app/client/ui/DocumentSettings'; import {showDocSettingsModal} from 'app/client/ui/DocumentSettings';
@ -36,7 +37,7 @@ import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {IPageWidgetLink, linkFromId, selectBy} from 'app/client/ui/selectBy'; import {IPageWidgetLink, linkFromId, selectBy} from 'app/client/ui/selectBy';
import {startWelcomeTour} from 'app/client/ui/welcomeTour'; import {startWelcomeTour} from 'app/client/ui/welcomeTour';
import {startDocTour} from "app/client/ui/DocTour"; import {startDocTour} from "app/client/ui/DocTour";
import {mediaSmall, testId} from 'app/client/ui2018/cssVars'; import {isNarrowScreen, mediaSmall, testId} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList'; import {IconName} from 'app/client/ui2018/IconList';
import {ActionGroup} from 'app/common/ActionGroup'; import {ActionGroup} from 'app/common/ActionGroup';
import {delay} from 'app/common/delay'; import {delay} from 'app/common/delay';
@ -135,6 +136,7 @@ export class GristDoc extends DisposableWithEvents {
private _docHistory: DocHistory; private _docHistory: DocHistory;
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard); private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
private _viewLayout: ViewLayout|null = null; private _viewLayout: ViewLayout|null = null;
private _showGristTour = getUserOrgPrefObs(this.docPageModel.appModel, 'showGristTour');
constructor( constructor(
public readonly app: App, public readonly app: App,
@ -209,19 +211,21 @@ export class GristDoc extends DisposableWithEvents {
// Start welcome tour if flag is present in the url hash. // Start welcome tour if flag is present in the url hash.
this.autoDispose(subscribe(urlState().state, async (_use, state) => { this.autoDispose(subscribe(urlState().state, async (_use, state) => {
if (state.welcomeTour || state.docTour) { if (state.welcomeTour || state.docTour || this._shouldAutoStartWelcomeTour()) {
// On boarding tours were not designed with mobile support in mind. Disable until fixed.
if (isNarrowScreen()) {
return;
}
await this._waitForView(); await this._waitForView();
await delay(0); // we need to wait an extra bit. await delay(0); // we need to wait an extra bit.
// TODO:
// 1) url needs cleanup, #repeat-welcome-tour sticks to it and so even when navigating // Remove any tour-related hash-tags from the URL. So #repeat-welcome-tour and
// to home page. This could eventually become an issue: if user opens another document it // #repeat-doc-tour are used as triggers, but will immediately disappear.
// would starts the onboarding tour again. await urlState().pushUrl({welcomeTour: false, docTour: false},
// 2) Makes sure the right panel is opened with the Column tab selected. Because some {replace: true, avoidReload: true});
// of the messages relates to that part of the UI.
// 3) On boarding tours were not designed with mobile support in mind. So probably a if (!state.docTour) {
// good idea to disable. startWelcomeTour(() => this._showGristTour.set(false));
if (state.welcomeTour) {
startWelcomeTour(() => null);
} else { } else {
await startDocTour(this.docData, () => null); await startDocTour(this.docData, () => null);
} }
@ -865,6 +869,24 @@ export class GristDoc extends DisposableWithEvents {
} }
return cursorPos; return cursorPos;
} }
/**
* For first-time users on personal org, start a welcome tour.
*/
private _shouldAutoStartWelcomeTour(): boolean {
// TODO: decide what to do when both a docTour and grist welcome tour are available.
// Only show the tour if one is on a personal org and can edit. This excludes templates (on
// the Templates org, which may have their own tour) and team sites (where user's intended
// role is often other than document creator).
const appModel = this.docPageModel.appModel;
if (!appModel.currentOrg?.owner || this.isReadonly.get()) {
return false;
}
// Use the showGristTour pref if set; otherwise default to true for anonymous users, and false
// for real returning users.
return this._showGristTour.get() ?? (!appModel.currentValidUser);
}
} }
async function finalizeAnchor() { async function finalizeAnchor() {

View File

@ -30,17 +30,17 @@ export function createHelpTools(appModel: AppModel, spacer = true): DomContents
spacer ? cssSpacer() : null, spacer ? cssSpacer() : null,
cssPageEntry( cssPageEntry(
cssPageLink(cssPageIcon('Feedback'), cssPageLink(cssPageIcon('Feedback'),
cssLinkText('Give Feedback', dom.cls('tour-feedback')), cssLinkText('Give Feedback'),
dom.on('click', () => beaconOpenMessage({appModel})), dom.on('click', () => beaconOpenMessage({appModel})),
), ),
dom.hide(isEfcr), dom.hide(isEfcr),
testId('left-feedback'), testId('left-feedback'),
), ),
cssPageEntry( cssPageEntry(
cssPageLink(cssPageIcon('Help'), {href: commonUrls.help, target: '_blank'}, cssLinkText( cssPageLink(cssPageIcon('Help'), {href: commonUrls.help, target: '_blank'},
'Help Center', cssLinkText('Help Center'),
dom.cls('tour-help-center') dom.cls('tour-help-center')
)), ),
dom.hide(isEfcr), dom.hide(isEfcr),
), ),
]; ];

View File

@ -22,7 +22,7 @@
* the caller. Pass an `onFinishCB` to handle when a user dimiss the popups. * the caller. Pass an `onFinishCB` to handle when a user dimiss the popups.
*/ */
import { Disposable, dom, DomElementArg, makeTestId, styled, svg } from "grainjs"; import { Disposable, dom, DomElementArg, Holder, makeTestId, styled, svg } from "grainjs";
import { createPopper, Placement } from '@popperjs/core'; import { createPopper, Placement } from '@popperjs/core';
import { FocusLayer } from 'app/client/lib/FocusLayer'; import { FocusLayer } from 'app/client/lib/FocusLayer';
import * as Mousetrap from 'app/client/lib/Mousetrap'; import * as Mousetrap from 'app/client/lib/Mousetrap';
@ -71,8 +71,12 @@ export interface IOnBoardingMsg {
urlState?: IGristUrlState; urlState?: IGristUrlState;
} }
// There should only be one tour at a time. Use a holder to dispose the previous tour when
// starting a new one.
const tourSingleton = Holder.create<OnBoardingPopupsCtl>(null);
export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: () => void) { export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: () => void) {
const ctl = new OnBoardingPopupsCtl(messages, onFinishCB); const ctl = OnBoardingPopupsCtl.create(tourSingleton, messages, onFinishCB);
ctl.start().catch(reportError); ctl.start().catch(reportError);
} }

View File

@ -183,7 +183,7 @@ export class RightPanel extends Disposable {
private _buildFieldContent(owner: MultiHolder) { private _buildFieldContent(owner: MultiHolder) {
const fieldBuilder = owner.autoDispose(ko.computed(() => { const fieldBuilder = owner.autoDispose(ko.computed(() => {
const vsi = this._gristDoc.viewModel.activeSection().viewInstance(); const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
return vsi && vsi.activeFieldBuilder(); return vsi && vsi.activeFieldBuilder();
})); }));
@ -197,7 +197,7 @@ export class RightPanel extends Disposable {
// build cursor position observable // build cursor position observable
const cursor = owner.autoDispose(ko.computed(() => { const cursor = owner.autoDispose(ko.computed(() => {
const vsi = this._gristDoc.viewModel.activeSection().viewInstance(); const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
return vsi?.cursor.currentPosition() ?? {}; return vsi?.cursor.currentPosition() ?? {};
})); }));

View File

@ -1,6 +1,9 @@
import * as commands from 'app/client/components/commands';
import { urlState } from 'app/client/models/gristUrlState';
import { IOnBoardingMsg, startOnBoarding } from "app/client/ui/OnBoardingPopups"; import { IOnBoardingMsg, startOnBoarding } from "app/client/ui/OnBoardingPopups";
import { colors } from 'app/client/ui2018/cssVars'; import { colors } from 'app/client/ui2018/cssVars';
import { icon } from "app/client/ui2018/icons"; import { icon } from "app/client/ui2018/icons";
import { cssLink } from "app/client/ui2018/links";
import { dom, styled } from "grainjs"; import { dom, styled } from "grainjs";
export const welcomeTour: IOnBoardingMsg[] = [ export const welcomeTour: IOnBoardingMsg[] = [
@ -58,30 +61,30 @@ export const welcomeTour: IOnBoardingMsg[] = [
}, },
{ {
selector: '.tour-help-center', selector: '.tour-help-center',
title: 'Keep learning', title: 'Flying higher',
body: () => [ body: () => [
dom('p', 'Unlock Grist\'s hidden power. Dive into our documentation, videos, ', dom('p', 'Use ', Key(GreyIcon('Help'), 'Help Center'), ' for documentation, videos, and tutorials.'),
'and tutorials to take your spreadsheet-database to the next level. '), dom('p', 'Use ', Key(GreyIcon('Feedback'), 'Give Feedback'), ' for issues or questions.'),
],
placement: 'right',
},
{
selector: '.tour-feedback',
title: 'Give feedback',
body: () => [
dom('p', 'Use ', Key('Give Feedback'), ' button (', Icon('Feedback'), ') for issues or questions. '),
], ],
placement: 'right', placement: 'right',
}, },
{ {
selector: '.tour-welcome', selector: '.tour-welcome',
title: 'Welcome to Grist!', title: 'Welcome to Grist!',
body: () => [
dom('p', 'Browse our ',
cssLink({target: '_blank', href: urlState().makeUrl({homePage: "templates"})},
'template library', cssInlineIcon('FieldLink')),
"to discover what's possible and get inspired."
),
],
showHasModal: true, showHasModal: true,
} }
]; ];
export function startWelcomeTour(onFinishCB: () => void) { export function startWelcomeTour(onFinishCB: () => void) {
commands.allCommands.fieldTabOpen.run();
startOnBoarding(welcomeTour, onFinishCB); startOnBoarding(welcomeTour, onFinishCB);
} }
@ -95,7 +98,8 @@ const KeyStrong = styled(KeyContent, `
font-weight: 700; font-weight: 700;
`); `);
const Key = styled('code', ` const Key = styled('div', `
display: inline-block;
padding: 2px 5px; padding: 2px 5px;
border-radius: 4px; border-radius: 4px;
margin: 0px 2px; margin: 0px 2px;
@ -110,3 +114,12 @@ const Key = styled('code', `
const Icon = styled(icon, ` const Icon = styled(icon, `
--icon-color: ${colors.lightGreen}; --icon-color: ${colors.lightGreen};
`); `);
const GreyIcon = styled(icon, `
--icon-color: ${colors.slate};
margin-right: 8px;
`);
const cssInlineIcon = styled(icon, `
margin: -3px 8px 0 4px;
`);

View File

@ -168,7 +168,7 @@ export const mediaXSmall = `(max-width: ${smallScreenWidth - 0.02}px)`;
export const mediaDeviceNotSmall = `(min-device-width: ${mediumScreenWidth}px)`; export const mediaDeviceNotSmall = `(min-device-width: ${mediumScreenWidth}px)`;
function isNarrowScreen() { export function isNarrowScreen() {
return window.innerWidth < mediumScreenWidth; return window.innerWidth < mediumScreenWidth;
} }

View File

@ -24,6 +24,11 @@ export interface UserOrgPrefs extends Prefs {
// By living in UserOrgPrefs, this applies only to the examples-containing org. // By living in UserOrgPrefs, this applies only to the examples-containing org.
seenExamples?: number[]; seenExamples?: number[];
// Whether the user should see the onboarding tour of Grist. False by default, since existing
// users should not see it. New users get this set to true when the user is created. This
// applies to the personal org only; the tour is currently only shown there.
showGristTour?: boolean;
// List of document IDs where the user has seen and dismissed the document tour. // List of document IDs where the user has seen and dismissed the document tour.
seenDocTours?: string[]; seenDocTours?: string[];
} }

View File

@ -207,6 +207,12 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
if (state.hash) { if (state.hash) {
// Project tests use hashes, so only set hash if there is an anchor. // Project tests use hashes, so only set hash if there is an anchor.
url.hash = hashParts.join('.'); url.hash = hashParts.join('.');
} else if (state.welcomeTour) {
url.hash = 'repeat-welcome-tour';
} else if (state.docTour) {
url.hash = 'repeat-doc-tour';
} else {
url.hash = '';
} }
return url.href; return url.href;
} }

View File

@ -5,6 +5,7 @@ import {canAddOrgMembers, Features} from 'app/common/Features';
import {buildUrlId, MIN_URLID_PREFIX_LENGTH, parseUrlId} from 'app/common/gristUrls'; import {buildUrlId, MIN_URLID_PREFIX_LENGTH, parseUrlId} from 'app/common/gristUrls';
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI'; import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
import {checkSubdomainValidity} from 'app/common/orgNameUtils'; import {checkSubdomainValidity} from 'app/common/orgNameUtils';
import {UserOrgPrefs} from 'app/common/Prefs';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
// TODO: API should implement UserAPI // TODO: API should implement UserAPI
import {ANONYMOUS_USER_EMAIL, DocumentProperties, EVERYONE_EMAIL, import {ANONYMOUS_USER_EMAIL, DocumentProperties, EVERYONE_EMAIL,
@ -531,6 +532,13 @@ export class HomeDBManager extends EventEmitter {
throw new Error(result.errMessage); throw new Error(result.errMessage);
} }
needUpdate = true; needUpdate = true;
// We just created a personal org; set userOrgPrefs that should apply for new users only.
const userOrgPrefs: UserOrgPrefs = {showGristTour: true};
const orgId = result.data;
if (orgId) {
await this.updateOrg({userId: user.id}, orgId, {userOrgPrefs}, manager);
}
} }
if (needUpdate) { if (needUpdate) {
// We changed the db - reload user in order to give consistent results. // We changed the db - reload user in order to give consistent results.
@ -1201,7 +1209,8 @@ export class HomeDBManager extends EventEmitter {
public async updateOrg( public async updateOrg(
scope: Scope, scope: Scope,
orgKey: string|number, orgKey: string|number,
props: Partial<OrganizationProperties> props: Partial<OrganizationProperties>,
transaction?: EntityManager,
): Promise<QueryResult<number>> { ): Promise<QueryResult<number>> {
// Check the scope of the modifications. // Check the scope of the modifications.
@ -1224,7 +1233,7 @@ export class HomeDBManager extends EventEmitter {
} }
// TODO: Unsetting a domain will likely have to be supported; also possibly prefs. // TODO: Unsetting a domain will likely have to be supported; also possibly prefs.
return await this._connection.transaction(async manager => { return await this._runInTransaction(transaction, async manager => {
const orgQuery = this.org(scope, orgKey, { const orgQuery = this.org(scope, orgKey, {
manager, manager,
markPermissions, markPermissions,

View File

@ -37,6 +37,7 @@ describe("Smoke", function() {
await openMainPage(); await openMainPage();
await driver.findContent('button', /Create Empty Document/).click(); await driver.findContent('button', /Create Empty Document/).click();
await gu.waitForDocToLoad(20000); await gu.waitForDocToLoad(20000);
await gu.dismissWelcomeTourIfNeeded();
await gu.getCell('A', 1).click(); await gu.getCell('A', 1).click();
await gu.enterCell('123'); await gu.enterCell('123');
await driver.navigate().refresh(); await driver.navigate().refresh();

View File

@ -154,6 +154,15 @@ export async function waitForUrl(pattern: RegExp|string, waitMs: number = 2000)
await driver.wait(() => testCurrentUrl(pattern), waitMs); await driver.wait(() => testCurrentUrl(pattern), waitMs);
} }
export async function dismissWelcomeTourIfNeeded() {
const elem = driver.find('.test-onboarding-close');
if (await elem.isPresent()) {
await elem.click();
}
await waitForServer();
}
// Selects all text when a text element is currently active. // Selects all text when a text element is currently active.
export async function selectAll() { export async function selectAll() {
await driver.executeScript('document.activeElement.select()'); await driver.executeScript('document.activeElement.select()');

View File

@ -52,14 +52,17 @@ export class HomeUtil {
loginMethod?: UserProfile['loginMethod'], loginMethod?: UserProfile['loginMethod'],
freshAccount?: boolean, freshAccount?: boolean,
isFirstLogin?: boolean, isFirstLogin?: boolean,
showGristTour?: boolean,
cacheCredentials?: boolean, cacheCredentials?: boolean,
} = {}) { } = {}) {
const {loginMethod, isFirstLogin} = defaults(options, {loginMethod: 'Email + Password'}); const {loginMethod, isFirstLogin} = defaults(options, {loginMethod: 'Email + Password'});
const showGristTour = options.showGristTour ?? (options.freshAccount ?? isFirstLogin);
// For regular tests, we can log in through a testing hook. // For regular tests, we can log in through a testing hook.
if (!this.server.isExternalServer()) { if (!this.server.isExternalServer()) {
if (options.freshAccount) { await this._deleteUserByEmail(email); } if (options.freshAccount) { await this._deleteUserByEmail(email); }
if (isFirstLogin !== undefined) { await this._setFirstLogin(email, isFirstLogin); } if (isFirstLogin !== undefined) { await this._setFirstLogin(email, isFirstLogin); }
if (showGristTour !== undefined) { await this._initShowGristTour(email, showGristTour); }
// TestingHooks communicates via JSON, so it's impossible to send an `undefined` value for org // 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. // through it. Using the empty string happens to work though.
const testingHooks = await this.server.getTestingHooks(); const testingHooks = await this.server.getTestingHooks();
@ -86,6 +89,9 @@ export class HomeUtil {
await this._fillWelcomePageIfPresent(name); await this._fillWelcomePageIfPresent(name);
} }
} }
if (options.freshAccount) {
this._apiKey.delete(email);
}
if (options.cacheCredentials) { if (options.cacheCredentials) {
// Take this opportunity to cache access info. // Take this opportunity to cache access info.
if (!this._apiKey.has(email)) { if (!this._apiKey.has(email)) {
@ -302,6 +308,16 @@ export class HomeUtil {
} }
} }
private async _initShowGristTour(email: string, showGristTour: boolean) {
if (this.server.isExternalServer()) { throw new Error('not supported'); }
const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email);
if (user && user.personalOrg) {
const userOrgPrefs = {showGristTour};
await dbManager.updateOrg({userId: user.id}, user.personalOrg.id, {userOrgPrefs});
}
}
// Get past the user welcome page if it is present. // Get past the user welcome page if it is present.
private async _fillWelcomePageIfPresent(name?: string) { private async _fillWelcomePageIfPresent(name?: string) {
// TODO: check treatment of welcome/team page when necessary. // TODO: check treatment of welcome/team page when necessary.