diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index b2faeee4..278fb865 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -35,6 +35,7 @@ import {showDocSettingsModal} from 'app/client/ui/DocumentSettings'; 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 {IconName} from 'app/client/ui2018/IconList'; import {ActionGroup} from 'app/common/ActionGroup'; @@ -196,7 +197,7 @@ 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) { + if (state.welcomeTour || state.docTour) { await this._waitForView(); await delay(0); // we need to wait an extra bit. // TODO: @@ -207,7 +208,11 @@ export class GristDoc extends DisposableWithEvents { // 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. - startWelcomeTour(() => null); + if (state.welcomeTour) { + startWelcomeTour(() => null); + } else { + await startDocTour(this.docData, () => null); + } } })); diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts index d1216898..443bca75 100644 --- a/app/client/models/gristUrlState.ts +++ b/app/client/models/gristUrlState.ts @@ -27,6 +27,7 @@ import {UrlState} from 'app/client/lib/UrlState'; import {decodeUrl, encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState, useNewUI} from 'app/common/gristUrls'; import {Document} from 'app/common/UserAPI'; import isEmpty = require('lodash/isEmpty'); +import {CellValue} from "app/plugin/GristData"; /** * Returns a singleton UrlState object, initializing it on first use. @@ -164,3 +165,49 @@ export class UrlStateImpl { } } } + +/** + * Given value like `foo bar baz`, constructs URL by checking if `baz` is a valid URL and, + * if not, prepending `http://`. + */ +export function constructUrl(value: CellValue): string { + if (typeof value !== 'string') { + return ''; + } + const url = value.slice(value.lastIndexOf(' ') + 1); + try { + // Try to construct a valid URL + return (new URL(url)).toString(); + } catch (e) { + // Not a valid URL, so try to prefix it with http + return 'http://' + url; + } +} + +/** + * If urlValue contains a URL to the current document that can be navigated to without a page reload, + * returns a parsed IGristUrlState that can be passed to urlState().pushState() to do that navigation. + * Otherwise, returns null. + */ +export function sameDocumentUrlState(urlValue: CellValue): IGristUrlState | null { + const urlString = constructUrl(urlValue); + let url: URL; + try { + url = new URL(urlString); + } catch { + return null; + } + const oldOrigin = window.location.origin; + const newOrigin = url.origin; + if (oldOrigin !== newOrigin) { + return null; + } + + const urlStateImpl = new UrlStateImpl(window as any); + const result = urlStateImpl.decodeUrl(url); + if (urlStateImpl.needPageLoad(urlState().state.get(), result)) { + return null; + } else { + return result; + } +} diff --git a/app/client/ui/DocTour.ts b/app/client/ui/DocTour.ts new file mode 100644 index 00000000..683564a6 --- /dev/null +++ b/app/client/ui/DocTour.ts @@ -0,0 +1,62 @@ +import {IOnBoardingMsg, startOnBoarding} from "app/client/ui/OnBoardingPopups"; +import {DocData} from "../../common/DocData"; +import * as _ from "lodash"; +import {Placement} from "@popperjs/core"; +import {placements} from "@popperjs/core/lib/enums"; +import {sameDocumentUrlState} from "../models/gristUrlState"; + + +export async function startDocTour(docData: DocData, onFinishCB: () => void) { + const docTour: IOnBoardingMsg[] = await makeDocTour(docData) || invalidDocTour; + (window as any)._gristDocTour = docTour; // for easy testing + startOnBoarding(docTour, onFinishCB); +} + +const invalidDocTour: IOnBoardingMsg[] = [{ + title: 'No valid document tour', + body: 'Cannot construct a document tour from the data in this document. ' + + 'Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location.', + selector: 'document', + showHasModal: true, +}]; + +async function makeDocTour(docData: DocData): Promise { + const tableId = "GristDocTour"; + if (!docData.getTable(tableId)) { + return null; + } + await docData.fetchTable(tableId); + const tableData = docData.getTable(tableId)!; + const result = _.sortBy(tableData.getRowIds(), tableData.getRowPropFunc('manualSort') as any).map(rowId => { + function getValue(colId: string): string { + return String(tableData.getValue(rowId, colId) || ""); + } + const title = getValue("Title"); + const body = getValue("Body"); + const locationValue = getValue("Location"); + let placement = getValue("Placement"); + + if (!(title || body)) { + return null; + } + + const urlState = sameDocumentUrlState(locationValue); + if (!placements.includes(placement as Placement)) { + placement = "auto"; + } + + return { + title, + body, + placement, + urlState, + selector: '.active_cursor', + // Center the popup if the user doesn't provide a link to a cell + showHasModal: !urlState?.hash + }; + }).filter(x => x !== null) as IOnBoardingMsg[]; + if (!result.length) { + return null; + } + return result; +} diff --git a/app/client/ui/OnBoardingPopups.ts b/app/client/ui/OnBoardingPopups.ts index bf3ea360..377f1c0b 100644 --- a/app/client/ui/OnBoardingPopups.ts +++ b/app/client/ui/OnBoardingPopups.ts @@ -29,6 +29,10 @@ import * as Mousetrap from 'app/client/lib/Mousetrap'; import { bigBasicButton, bigPrimaryButton } from "app/client/ui2018/buttons"; import { colors, vars } from "app/client/ui2018/cssVars"; import range = require("lodash/range"); +import {IGristUrlState} from "app/common/gristUrls"; +import {urlState} from "app/client/models/gristUrlState"; +import {delay} from "app/common/delay"; +import {reportError} from "app/client/models/errors"; const testId = makeTestId('test-onboarding-'); @@ -61,11 +65,14 @@ export interface IOnBoardingMsg { // Skip the message skip?: boolean; + + // If present, will be passed to urlState().pushUrl() to navigate to the location defined by that state + urlState?: IGristUrlState; } export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: () => void) { const ctl = new OnBoardingPopupsCtl(messages, onFinishCB); - ctl.start(); + ctl.start().catch(reportError); } class OnBoardingError extends Error { @@ -91,9 +98,9 @@ class OnBoardingPopupsCtl extends Disposable { }); } - public start(): void { + public async start() { this._showOverlay(); - this._next(); + await this._next(); Mousetrap.setPaused(true); this.onDispose(() => { Mousetrap.setPaused(false); @@ -105,22 +112,27 @@ class OnBoardingPopupsCtl extends Disposable { this.dispose(); } - private _next(): void { + private async _next() { this._index = this._index + 1; const entry = this._messages[this._index]; - if (entry.skip) { this._next(); } + if (entry.skip) { await this._next(); } // close opened popup if any this._openPopupCtl?.close(); + if (entry.urlState) { + await urlState().pushUrl(entry.urlState); + await delay(100); // make sure cursor is in correct place + } + if (entry.showHasModal) { this._showHasModal(); } else { - this._showHasPopup(); + await this._showHasPopup(); } } - private _showHasPopup() { + private async _showHasPopup() { const content = this._buildPopupContent(); const entry = this._messages[this._index]; const elem = document.querySelector(entry.selector); diff --git a/app/client/widgets/HyperLinkTextBox.ts b/app/client/widgets/HyperLinkTextBox.ts index 864f51d5..21d0ca29 100644 --- a/app/client/widgets/HyperLinkTextBox.ts +++ b/app/client/widgets/HyperLinkTextBox.ts @@ -5,7 +5,7 @@ import {colors, testId} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {NTextBox} from 'app/client/widgets/NTextBox'; import {dom, styled} from 'grainjs'; -import {urlState, UrlStateImpl} from "../models/gristUrlState"; +import {constructUrl, sameDocumentUrlState, urlState} from "app/client/models/gristUrlState"; /** * Creates a widget for displaying links. Links can entered directly or following a title. @@ -24,10 +24,10 @@ export class HyperLinkTextBox extends NTextBox { dom.cls('text_wrapping', this.wrapping), dom.maybe((use) => Boolean(use(value)), () => dom('a', - dom.attr('href', (use) => _constructUrl(use(value))), + dom.attr('href', (use) => constructUrl(use(value))), dom.attr('target', '_blank'), dom.on('click', (ev) => - _onClickHyperlink(ev, new URL(_constructUrl(value.peek())))), + _onClickHyperlink(ev, value.peek())), // As per Google and Mozilla recommendations to prevent opened links // from running on the same process as Grist: // https://developers.google.com/web/tools/lighthouse/audits/noopener @@ -50,37 +50,16 @@ function _formatValue(value: CellValue): string { return index >= 0 ? value.slice(0, index) : value; } -/** - * Given value like `foo bar baz`, constructs URL by checking if `baz` is a valid URL and, - * if not, prepending `http://`. - */ -function _constructUrl(value: CellValue): string { - if (typeof value !== 'string') { return ''; } - const url = value.slice(value.lastIndexOf(' ') + 1); - try { - // Try to construct a valid URL - return (new URL(url)).toString(); - } catch (e) { - // Not a valid URL, so try to prefix it with http - return 'http://' + url; - } -} - /** * If possible (i.e. if `url` points to somewhere in the current document) * use pushUrl to navigate without reloading or opening a new tab */ -async function _onClickHyperlink(ev: MouseEvent, url: URL) { +async function _onClickHyperlink(ev: MouseEvent, url: CellValue) { // Only override plain-vanilla clicks. if (ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) { return; } - const oldOrigin = window.location.origin; - const newOrigin = url.origin; - if (oldOrigin !== newOrigin) { return; } - - const urlStateImpl = new UrlStateImpl(window as any); - const newUrlState = urlStateImpl.decodeUrl(url); - if (urlStateImpl.needPageLoad(urlState().state.get(), newUrlState)) { return; } + const newUrlState = sameDocumentUrlState(url); + if (!newUrlState) { return; } ev.preventDefault(); await urlState().pushUrl(newUrlState); diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 8ec9f732..ffbcd5a9 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -61,6 +61,7 @@ export interface IGristUrlState { billing?: BillingPage; welcome?: WelcomePage; welcomeTour?: boolean; + docTour?: boolean; params?: { billingPlan?: string; billingTask?: BillingTask; @@ -291,15 +292,14 @@ export function decodeUrl(gristConfig: Partial, location: Locat } if (hashMap.has('#') && hashMap.get('#') === 'a1') { const link: HashLink = {}; - for (const key of ['sectionId', 'rowId', 'colRef'] as Array>) { + for (const key of ['sectionId', 'rowId', 'colRef'] as Array>) { const ch = key.substr(0, 1); if (hashMap.has(ch)) { link[key] = parseInt(hashMap.get(ch)!, 10); } } state.hash = link; } - if (hashMap.has('#') && hashMap.get('#') === 'repeat-welcome-tour') { - state.welcomeTour = true; - } + state.welcomeTour = hashMap.get('#') === 'repeat-welcome-tour'; + state.docTour = hashMap.get('#') === 'repeat-doc-tour'; } return state; }