(core) Fix bugs when both welcomeTour and docTour are available

Summary:
- Unify where in the code tours get initiated.
- Avoid start a new tour while one is being started or is in progress.
- Ignore welcome tour when on a doc that has a doc tour.
- Fix tours when starting with a special page like Access Rules.
- Remove mention of the no-longer-present "Give Feedback" button in the last
  message of the welcome tour.

Test Plan:
Add a browser test case that docTour preempts the welcome tour and shows no errors
(this test case fails in multiple ways without the changes).

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3330
This commit is contained in:
Dmitry S 2022-03-20 23:41:59 -04:00
parent 1452b6efc3
commit 3b76b33423
5 changed files with 79 additions and 52 deletions

View File

@ -35,11 +35,12 @@ 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 {getFilterFunc, QuerySetManager} from 'app/client/models/QuerySet'; import {getFilterFunc, QuerySetManager} from 'app/client/models/QuerySet';
import {getUserOrgPrefObs, getUserOrgPrefsObs} from 'app/client/models/UserPrefs'; import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} 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 {startDocTour} from "app/client/ui/DocTour"; import {startDocTour} from "app/client/ui/DocTour";
import {showDocSettingsModal} from 'app/client/ui/DocumentSettings'; import {showDocSettingsModal} from 'app/client/ui/DocumentSettings';
import {isTourActive} from "app/client/ui/OnBoardingPopups";
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; 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';
@ -132,6 +133,9 @@ export class GristDoc extends DisposableWithEvents {
public readonly userOrgPrefs = getUserOrgPrefsObs(this.docPageModel.appModel); public readonly userOrgPrefs = getUserOrgPrefsObs(this.docPageModel.appModel);
// If the doc has a docTour. Used also to enable the UI button to restart the tour.
public readonly hasDocTour: Computed<boolean>;
private _actionLog: ActionLog; private _actionLog: ActionLog;
private _undoStack: UndoStack; private _undoStack: UndoStack;
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null; private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
@ -140,6 +144,7 @@ export class GristDoc extends DisposableWithEvents {
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.userOrgPrefs, 'showGristTour'); private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours');
constructor( constructor(
public readonly app: App, public readonly app: App,
@ -162,6 +167,9 @@ export class GristDoc extends DisposableWithEvents {
// Maintain the MetaRowModel for the global document info, including docId and peers. // Maintain the MetaRowModel for the global document info, including docId and peers.
this.docInfo = this.docModel.docInfoRow; this.docInfo = this.docModel.docInfoRow;
this.hasDocTour = Computed.create(this, use =>
use(this.docModel.allTableIds.getObservable()).includes('GristDocTour'));
const defaultViewId = this.docInfo.newDefaultViewId; const defaultViewId = this.docInfo.newDefaultViewId;
// Grainjs observable for current view id, which may be a string such as 'code'. // Grainjs observable for current view id, which may be a string such as 'code'.
@ -217,12 +225,23 @@ 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.
let tourStarting = false;
this.autoDispose(subscribe(urlState().state, async (_use, state) => { this.autoDispose(subscribe(urlState().state, async (_use, state) => {
if (state.welcomeTour || state.docTour || this._shouldAutoStartWelcomeTour()) { // Onboarding tours were not designed with mobile support in mind. Disable until fixed.
// On boarding tours were not designed with mobile support in mind. Disable until fixed.
if (isNarrowScreen()) { if (isNarrowScreen()) {
return; return;
} }
// If we have an active tour (or are in the process of starting one), don't start a new one.
if (tourStarting || isTourActive()) {
return;
}
const autoStartDocTour = this.hasDocTour.get() && !this._seenDocTours.get()?.includes(this.docId());
const docTour = state.docTour || autoStartDocTour;
const welcomeTour = state.welcomeTour || this._shouldAutoStartWelcomeTour();
if (welcomeTour || docTour) {
tourStarting = true;
try {
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.
@ -231,10 +250,14 @@ export class GristDoc extends DisposableWithEvents {
await urlState().pushUrl({welcomeTour: false, docTour: false}, await urlState().pushUrl({welcomeTour: false, docTour: false},
{replace: true, avoidReload: true}); {replace: true, avoidReload: true});
if (!state.docTour) { if (!docTour) {
startWelcomeTour(() => this._showGristTour.set(false)); startWelcomeTour(() => this._showGristTour.set(false));
} else { } else {
await startDocTour(this.docData, this.docComm, () => null); const onFinishCB = () => (autoStartDocTour && markAsSeen(this._seenDocTours, this.docId()));
await startDocTour(this.docData, this.docComm, onFinishCB);
}
} finally {
tourStarting = false;
} }
} }
})); }));
@ -801,6 +824,10 @@ export class GristDoc extends DisposableWithEvents {
* Waits for a view to be ready * Waits for a view to be ready
*/ */
private async _waitForView() { private async _waitForView() {
// For pages like ACL's, there isn't a view instance to wait for.
if (!this.viewModel.activeSection.peek().getRowId()) {
return;
}
const view = await waitObs(this.viewModel.activeSection.peek().viewInstance); const view = await waitObs(this.viewModel.activeSection.peek().viewInstance);
await view?.getLoadingDonePromise(); await view?.getLoadingDonePromise();
return view; return view;
@ -929,7 +956,11 @@ export class GristDoc extends DisposableWithEvents {
* For first-time users on personal org, start a welcome tour. * For first-time users on personal org, start a welcome tour.
*/ */
private _shouldAutoStartWelcomeTour(): boolean { private _shouldAutoStartWelcomeTour(): boolean {
// TODO: decide what to do when both a docTour and grist welcome tour are available. // When both a docTour and grist welcome tour are available, show only the docTour, leaving
// the welcome tour for another doc (e.g. a new one).
if (this.hasDocTour.get()) {
return false;
}
// Only show the tour if one is on a personal org and can edit. This excludes templates (on // 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 // the Templates org, which may have their own tour) and team sites (where user's intended

View File

@ -58,3 +58,23 @@ function makePrefFunctions<P extends keyof PrefsTypes>(prefsTypeName: P) {
export const {getPrefsObs: getUserOrgPrefsObs, getPrefObs: getUserOrgPrefObs} = makePrefFunctions('userOrgPrefs'); export const {getPrefsObs: getUserOrgPrefsObs, getPrefObs: getUserOrgPrefObs} = makePrefFunctions('userOrgPrefs');
export const {getPrefsObs: getUserPrefsObs, getPrefObs: getUserPrefObs} = makePrefFunctions('userPrefs'); export const {getPrefsObs: getUserPrefsObs, getPrefObs: getUserPrefObs} = makePrefFunctions('userPrefs');
// For preferences that store a list of items (such as seen docTours), this helper updates the
// preference to add itemId to it (e.g. to avoid auto-starting the docTour again in the future).
// prefKey is used only to log a more informative warning on error.
export function markAsSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T) {
const seenIds = seenIdsObs.get() || [];
try {
if (!seenIds.includes(itemId)) {
const seen = new Set(seenIds);
seen.add(itemId);
seenIdsObs.set([...seen].sort());
}
} catch (e) {
// If we fail to save this preference, it's probably not worth alerting the user about,
// so just log to console.
// tslint:disable-next-line:no-console
console.warn("Failed to save preference in markAsSeen", e);
}
}

View File

@ -80,6 +80,11 @@ export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: () => vo
ctl.start().catch(reportError); ctl.start().catch(reportError);
} }
// Returns whether some tour is currently active.
export function isTourActive(): boolean {
return !tourSingleton.isEmpty();
}
class OnBoardingError extends Error { class OnBoardingError extends Error {
public name = 'OnBoardingError'; public name = 'OnBoardingError';
constructor(message: string) { constructor(message: string) {

View File

@ -1,7 +1,6 @@
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {loadGristDoc} from 'app/client/lib/imports';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
import {getUserOrgPrefObs} from 'app/client/models/UserPrefs'; import {getUserOrgPrefObs, markAsSeen} from 'app/client/models/UserPrefs';
import {showExampleCard} from 'app/client/ui/ExampleCard'; import {showExampleCard} from 'app/client/ui/ExampleCard';
import {examples} from 'app/client/ui/ExampleInfo'; import {examples} from 'app/client/ui/ExampleInfo';
import {createHelpTools, cssLinkText, cssPageEntry, cssPageEntryMain, cssPageEntrySmall, import {createHelpTools, cssLinkText, cssPageEntry, cssPageEntryMain, cssPageEntrySmall,
@ -14,7 +13,7 @@ import {cssLink} from 'app/client/ui2018/links';
import {menuAnnotate} from 'app/client/ui2018/menus'; import {menuAnnotate} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals'; import {confirmModal} from 'app/client/ui2018/modals';
import {userOverrideParams} from 'app/common/gristUrls'; import {userOverrideParams} from 'app/common/gristUrls';
import {Computed, Disposable, dom, makeTestId, Observable, observable, styled} from 'grainjs'; import {Disposable, dom, makeTestId, Observable, observable, styled} from 'grainjs';
const testId = makeTestId('test-tools-'); const testId = makeTestId('test-tools-');
@ -22,8 +21,6 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
const docPageModel = gristDoc.docPageModel; const docPageModel = gristDoc.docPageModel;
const isOwner = docPageModel.currentDoc.get()?.access === 'owners'; const isOwner = docPageModel.currentDoc.get()?.access === 'owners';
const isOverridden = Boolean(docPageModel.userOverride.get()); const isOverridden = Boolean(docPageModel.userOverride.get());
const hasDocTour = Computed.create(owner, use =>
use(gristDoc.docModel.allTableIds.getObservable()).includes('GristDocTour'));
const canViewAccessRules = observable(false); const canViewAccessRules = observable(false);
function updateCanViewAccessRules() { function updateCanViewAccessRules() {
canViewAccessRules.set((isOwner && !isOverridden) || canViewAccessRules.set((isOwner && !isOverridden) ||
@ -106,20 +103,12 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
); );
}), }),
// Show the 'Tour of this Document' button if a GristDocTour table exists. // Show the 'Tour of this Document' button if a GristDocTour table exists.
dom.maybe(hasDocTour, () => dom.maybe(gristDoc.hasDocTour, () =>
cssSplitPageEntry( cssSplitPageEntry(
cssPageEntryMain( cssPageEntryMain(
cssPageLink(cssPageIcon('Page'), cssPageLink(cssPageIcon('Page'),
cssLinkText('Tour of this Document'), cssLinkText('Tour of this Document'),
automaticHelpTool( urlState().setLinkUrl({docTour: true}),
async ({markAsSeen}) => {
const gristDocModule = await loadGristDoc();
await gristDocModule.startDocTour(gristDoc.docData, gristDoc.docComm, markAsSeen);
},
gristDoc,
"seenDocTours",
gristDoc.docId()
),
testId('doctour'), testId('doctour'),
), ),
), ),
@ -165,24 +154,7 @@ function automaticHelpTool(
return; return;
} }
// When the help is closed, if it's the first time it's dismissed, save this fact, to avoid showFunc({elem, reopen, markAsSeen: () => markAsSeen(prefObs, itemId)});
// showing it automatically again in the future.
function markAsSeen() {
try {
if (!seenIds.includes(itemId)) {
const seen = new Set(seenIds);
seen.add(itemId);
prefObs.set([...seen].sort());
}
} catch (e) {
// If we fail to save this preference, it's probably not worth alerting the user about,
// so just log to console.
// tslint:disable-next-line:no-console
console.warn("Failed to save userPref " + prefKey, e);
}
}
showFunc({elem, reopen, markAsSeen});
} }
return [ return [

View File

@ -63,8 +63,7 @@ export const welcomeTour: IOnBoardingMsg[] = [
selector: '.tour-help-center', selector: '.tour-help-center',
title: 'Flying higher', title: 'Flying higher',
body: () => [ body: () => [
dom('p', 'Use ', Key(GreyIcon('Help'), 'Help Center'), ' for documentation, videos, and tutorials.'), dom('p', 'Use ', Key(GreyIcon('Help'), 'Help Center'), ' for documentation or questions.'),
dom('p', 'Use ', Key(GreyIcon('Feedback'), 'Give Feedback'), ' for issues or questions.'),
], ],
placement: 'right', placement: 'right',
}, },