diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 0b85cf5d..5b85142c 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -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() { diff --git a/app/client/ui/LeftPanelCommon.ts b/app/client/ui/LeftPanelCommon.ts index d108fc50..cf9c5a15 100644 --- a/app/client/ui/LeftPanelCommon.ts +++ b/app/client/ui/LeftPanelCommon.ts @@ -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), ), ]; diff --git a/app/client/ui/OnBoardingPopups.ts b/app/client/ui/OnBoardingPopups.ts index 52707251..b06bd229 100644 --- a/app/client/ui/OnBoardingPopups.ts +++ b/app/client/ui/OnBoardingPopups.ts @@ -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(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); } diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 8f15b0cb..68cb4783 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -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() ?? {}; })); diff --git a/app/client/ui/welcomeTour.ts b/app/client/ui/welcomeTour.ts index 13944126..674fc22b 100644 --- a/app/client/ui/welcomeTour.ts +++ b/app/client/ui/welcomeTour.ts @@ -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; +`); diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 2770a49c..47adea6b 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -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; } diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts index 89a3f3f2..31789959 100644 --- a/app/common/Prefs.ts +++ b/app/common/Prefs.ts @@ -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[]; } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index ea3f6dac..0bc98671 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -207,6 +207,12 @@ export function encodeUrl(gristConfig: Partial, 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; } diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 55089937..77fd8688 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -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 + props: Partial, + transaction?: EntityManager, ): Promise> { // 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, diff --git a/test/nbrowser/Smoke.ts b/test/nbrowser/Smoke.ts index 3efbec8b..c621e574 100644 --- a/test/nbrowser/Smoke.ts +++ b/test/nbrowser/Smoke.ts @@ -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(); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 88c5177a..66eee089 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -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()'); diff --git a/test/nbrowser/homeUtil.ts b/test/nbrowser/homeUtil.ts index 78bfe2ef..9321a798 100644 --- a/test/nbrowser/homeUtil.ts +++ b/test/nbrowser/homeUtil.ts @@ -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.