(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
pull/69/head
Dmitry S 3 years ago
parent 73c4efa315
commit 1605e18f66

@ -29,6 +29,7 @@ import {DocPageModel} from 'app/client/models/DocPageModel';
import {UserError} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState';
import {QuerySetManager} from 'app/client/models/QuerySet';
import {getUserOrgPrefObs} from "app/client/models/UserPrefs";
import {App} from 'app/client/ui/App';
import {DocHistory} from 'app/client/ui/DocHistory';
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 {startWelcomeTour} from 'app/client/ui/welcomeTour';
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 {ActionGroup} from 'app/common/ActionGroup';
import {delay} from 'app/common/delay';
@ -135,6 +136,7 @@ export class GristDoc extends DisposableWithEvents {
private _docHistory: DocHistory;
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
private _viewLayout: ViewLayout|null = null;
private _showGristTour = getUserOrgPrefObs(this.docPageModel.appModel, 'showGristTour');
constructor(
public readonly app: App,
@ -209,19 +211,21 @@ export class GristDoc extends DisposableWithEvents {
// Start welcome tour if flag is present in the url hash.
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 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
// to home page. This could eventually become an issue: if user opens another document it
// would starts the onboarding tour again.
// 2) Makes sure the right panel is opened with the Column tab selected. Because some
// 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
// good idea to disable.
if (state.welcomeTour) {
startWelcomeTour(() => null);
// Remove any tour-related hash-tags from the URL. So #repeat-welcome-tour and
// #repeat-doc-tour are used as triggers, but will immediately disappear.
await urlState().pushUrl({welcomeTour: false, docTour: false},
{replace: true, avoidReload: true});
if (!state.docTour) {
startWelcomeTour(() => this._showGristTour.set(false));
} else {
await startDocTour(this.docData, () => null);
}
@ -865,6 +869,24 @@ export class GristDoc extends DisposableWithEvents {
}
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() {

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

@ -22,7 +22,7 @@
* 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 { FocusLayer } from 'app/client/lib/FocusLayer';
import * as Mousetrap from 'app/client/lib/Mousetrap';
@ -71,8 +71,12 @@ export interface IOnBoardingMsg {
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) {
const ctl = new OnBoardingPopupsCtl(messages, onFinishCB);
const ctl = OnBoardingPopupsCtl.create(tourSingleton, messages, onFinishCB);
ctl.start().catch(reportError);
}

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

@ -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 { colors } from 'app/client/ui2018/cssVars';
import { icon } from "app/client/ui2018/icons";
import { cssLink } from "app/client/ui2018/links";
import { dom, styled } from "grainjs";
export const welcomeTour: IOnBoardingMsg[] = [
@ -58,30 +61,30 @@ export const welcomeTour: IOnBoardingMsg[] = [
},
{
selector: '.tour-help-center',
title: 'Keep learning',
title: 'Flying higher',
body: () => [
dom('p', 'Unlock Grist\'s hidden power. Dive into our documentation, videos, ',
'and tutorials to take your spreadsheet-database to the next level. '),
],
placement: 'right',
},
{
selector: '.tour-feedback',
title: 'Give feedback',
body: () => [
dom('p', 'Use ', Key('Give Feedback'), ' button (', Icon('Feedback'), ') for issues or questions. '),
dom('p', 'Use ', Key(GreyIcon('Help'), 'Help Center'), ' for documentation, videos, and tutorials.'),
dom('p', 'Use ', Key(GreyIcon('Feedback'), 'Give Feedback'), ' for issues or questions.'),
],
placement: 'right',
},
{
selector: '.tour-welcome',
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,
}
];
export function startWelcomeTour(onFinishCB: () => void) {
commands.allCommands.fieldTabOpen.run();
startOnBoarding(welcomeTour, onFinishCB);
}
@ -95,7 +98,8 @@ const KeyStrong = styled(KeyContent, `
font-weight: 700;
`);
const Key = styled('code', `
const Key = styled('div', `
display: inline-block;
padding: 2px 5px;
border-radius: 4px;
margin: 0px 2px;
@ -110,3 +114,12 @@ const Key = styled('code', `
const Icon = styled(icon, `
--icon-color: ${colors.lightGreen};
`);
const GreyIcon = styled(icon, `
--icon-color: ${colors.slate};
margin-right: 8px;
`);
const cssInlineIcon = styled(icon, `
margin: -3px 8px 0 4px;
`);

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

@ -24,6 +24,11 @@ export interface UserOrgPrefs extends Prefs {
// By living in UserOrgPrefs, this applies only to the examples-containing org.
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.
seenDocTours?: string[];
}

@ -207,6 +207,12 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
if (state.hash) {
// Project tests use hashes, so only set hash if there is an anchor.
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;
}

@ -5,6 +5,7 @@ import {canAddOrgMembers, Features} from 'app/common/Features';
import {buildUrlId, MIN_URLID_PREFIX_LENGTH, parseUrlId} from 'app/common/gristUrls';
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
import {UserOrgPrefs} from 'app/common/Prefs';
import * as roles from 'app/common/roles';
// TODO: API should implement UserAPI
import {ANONYMOUS_USER_EMAIL, DocumentProperties, EVERYONE_EMAIL,
@ -531,6 +532,13 @@ export class HomeDBManager extends EventEmitter {
throw new Error(result.errMessage);
}
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) {
// We changed the db - reload user in order to give consistent results.
@ -1201,7 +1209,8 @@ export class HomeDBManager extends EventEmitter {
public async updateOrg(
scope: Scope,
orgKey: string|number,
props: Partial<OrganizationProperties>
props: Partial<OrganizationProperties>,
transaction?: EntityManager,
): Promise<QueryResult<number>> {
// 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.
return await this._connection.transaction(async manager => {
return await this._runInTransaction(transaction, async manager => {
const orgQuery = this.org(scope, orgKey, {
manager,
markPermissions,

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

@ -154,6 +154,15 @@ export async function waitForUrl(pattern: RegExp|string, waitMs: number = 2000)
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.
export async function selectAll() {
await driver.executeScript('document.activeElement.select()');

@ -52,14 +52,17 @@ export class HomeUtil {
loginMethod?: UserProfile['loginMethod'],
freshAccount?: boolean,
isFirstLogin?: boolean,
showGristTour?: boolean,
cacheCredentials?: boolean,
} = {}) {
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.
if (!this.server.isExternalServer()) {
if (options.freshAccount) { await this._deleteUserByEmail(email); }
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
// through it. Using the empty string happens to work though.
const testingHooks = await this.server.getTestingHooks();
@ -86,6 +89,9 @@ export class HomeUtil {
await this._fillWelcomePageIfPresent(name);
}
}
if (options.freshAccount) {
this._apiKey.delete(email);
}
if (options.cacheCredentials) {
// Take this opportunity to cache access info.
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.
private async _fillWelcomePageIfPresent(name?: string) {
// TODO: check treatment of welcome/team page when necessary.

Loading…
Cancel
Save