diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index cba2c099..b84e2a81 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -34,6 +34,7 @@ import {App} from 'app/client/ui/App'; import {DocHistory} from 'app/client/ui/DocHistory'; 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 {mediaSmall, testId} from 'app/client/ui2018/cssVars'; import {IconName} from 'app/client/ui2018/IconList'; import {ActionGroup} from 'app/common/ActionGroup'; @@ -192,6 +193,23 @@ 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) { + 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. + startWelcomeTour(() => null); + } + })); + // Importer takes a function for creating previews. const createPreview = (vs: ViewSectionRec) => GridView.create(this, vs, true); diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 0edff831..1543ae1b 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -152,7 +152,8 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { // "Add New" menu should have the same width as the "Add New" button that opens it. stretchToSelector: `.${cssAddNewButton.className}` }), - testId('dp-add-new') + testId('dp-add-new'), + dom.cls('tour-add-new'), ), cssScrollPane( dom.create(buildPagesDom, activeDoc, leftPanelOpen), diff --git a/app/client/ui/LeftPanelCommon.ts b/app/client/ui/LeftPanelCommon.ts index 147b15c4..d108fc50 100644 --- a/app/client/ui/LeftPanelCommon.ts +++ b/app/client/ui/LeftPanelCommon.ts @@ -29,13 +29,18 @@ export function createHelpTools(appModel: AppModel, spacer = true): DomContents return [ spacer ? cssSpacer() : null, cssPageEntry( - cssPageLink(cssPageIcon('Feedback'), cssLinkText('Give Feedback'), - dom.on('click', () => beaconOpenMessage({appModel}))), + cssPageLink(cssPageIcon('Feedback'), + cssLinkText('Give Feedback', dom.cls('tour-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 0346e82f..bf3ea360 100644 --- a/app/client/ui/OnBoardingPopups.ts +++ b/app/client/ui/OnBoardingPopups.ts @@ -7,12 +7,12 @@ * Usage: * * // create the list of message - * const messages = [{id: 'add-new-btn', buildDom: () => dom('div', 'Adds New button let's you ...')}, + * const messages = [{id: 'add-new-btn', placement: 'right', buildDom: () => ... }, * {id: 'share-btn', buildDom: () => ... ]; * * * // attach each message to the corresponding element - * dom('div', 'Add New', ..., attachOnBoardingMsg('add-new-btn', {placement: 'right'})); + * dom('div', 'Add New', ..., dom.cls('tour-add-new-btn')); * * // start * startOnBoarding(message, onFinishCB); @@ -22,22 +22,45 @@ * the caller. Pass an `onFinishCB` to handle when a user dimiss the popups. */ -import { Disposable, dom, DomElementMethod, makeTestId, styled, svg } from "grainjs"; -import { createPopper, Placement, Options as PopperOptions } from '@popperjs/core'; -import { pull, range } from "lodash"; -import { bigBasicButton, bigPrimaryButton } from "../ui2018/buttons"; -import { colors } from "../ui2018/cssVars"; +import { Disposable, dom, DomElementArg, 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'; +import { bigBasicButton, bigPrimaryButton } from "app/client/ui2018/buttons"; +import { colors, vars } from "app/client/ui2018/cssVars"; +import range = require("lodash/range"); const testId = makeTestId('test-onboarding-'); // Describes an onboarding popup. Each popup is uniquely identified by its id. export interface IOnBoardingMsg { - // Identifies one message - id: string, + // A CSS selector pointing to the reference element + selector: string, - // Build the popup's content - buildDom: () => HTMLElement, + // Title + title: DomElementArg, + + // Body + body?: DomElementArg, + + // If true show the message as a modal centered on the screen. + showHasModal?: boolean, + + // The popper placement. + placement?: Placement, + + // Adjusts the popup offset so that it is positioned relative to the content of the reference + // element. This is useful when the reference element has padding and no border (ie: such as + // icons). In which case, and when set to true, it will fill the gap between popups and the UI + // part it's pointing at. If `cropPadding` is falsy otherwise, the popup might look a bit distant. + cropPadding?: boolean, + + // The popper offset. + offset?: [number, number], + + // Skip the message + skip?: boolean; } export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: () => void) { @@ -45,40 +68,13 @@ export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: () => vo ctl.start(); } -// Onboarding popup options. -export interface IOnBoardingPopupOptions { - placement?: Placement; +class OnBoardingError extends Error { + public name = 'OnBoardingError'; + constructor(message: string) { + super(message); + } } -function attachOnBoardingElem(elem: HTMLElement, messageId: string, opts: IOnBoardingPopupOptions) { - const val = {elem, messageId, ...opts}; - registry.push(val); - dom.onDisposeElem(elem, () => pull(registry, val)); -} - -// A dom method that let you attach an boarding message to an element. This causes the onboarding -// message to be shown in a tooltip like popup pointing at this element. More info in the module -// description. -export function attachOnBoardingMsg(messageId: string, opts: IOnBoardingPopupOptions): DomElementMethod { - return (elem) => attachOnBoardingElem(elem, messageId, opts); -} - -// Onboarding popup's options. -interface IOnboardingRegistryItem { - - // the message id - messageId: string; - - // The popper placement, optional. - placement?: Placement, - - // the element, - elem: HTMLElement, -} - -// List of all registered element -const registry: Array = []; - class OnBoardingPopupsCtl extends Disposable { private _index = -1; private _openPopupCtl: {close: () => void}|undefined; @@ -88,7 +84,7 @@ class OnBoardingPopupsCtl extends Disposable { constructor(private _messages: IOnBoardingMsg[], private _onFinishCB: () => void) { super(); if (this._messages.length === 0) { - throw new Error('messages should not be an empty list'); + throw new OnBoardingError('messages should not be an empty list'); } this.onDispose(() => { this._openPopupCtl?.close(); @@ -98,6 +94,10 @@ class OnBoardingPopupsCtl extends Disposable { public start(): void { this._showOverlay(); this._next(); + Mousetrap.setPaused(true); + this.onDispose(() => { + Mousetrap.setPaused(false); + }); } private _finish() { @@ -107,14 +107,32 @@ class OnBoardingPopupsCtl extends Disposable { private _next(): void { this._index = this._index + 1; - const {id} = this._messages[this._index]; - const entry = registry.find((opts) => opts.messageId === id); - if (!entry) { throw new Error(`Missing on-boarding entry for message: ${id}`); } - const {elem, placement} = entry; + const entry = this._messages[this._index]; + if (entry.skip) { this._next(); } // close opened popup if any this._openPopupCtl?.close(); + if (entry.showHasModal) { + this._showHasModal(); + } else { + this._showHasPopup(); + } + } + + private _showHasPopup() { + const content = this._buildPopupContent(); + const entry = this._messages[this._index]; + const elem = document.querySelector(entry.selector); + const {placement} = entry; + + // The element the popup refers to is not present. To the user we show nothing and simply skip + // it to the next. + if (!elem) { + console.warn(`On boarding tour: element ${entry.selector} not found!`); + return this._next(); + } + // Cleanup function close() { popper.destroy(); @@ -122,14 +140,14 @@ class OnBoardingPopupsCtl extends Disposable { content.remove(); } - // Add the content element - const content = this._buildPopupContent(); + this._openPopupCtl = {close}; document.body.appendChild(content); + this._addFocusLayer(content); // Create a popper for positioning the popup content relative to the reference element - const popperOptions: Partial = { placement }; + const adjacentPadding = entry.cropPadding ? this._getAdjacentPadding(elem, placement) : 0; const popper = createPopper(elem, content, { - ...popperOptions, + placement, modifiers: [{ name: 'arrow', options: { @@ -138,19 +156,66 @@ class OnBoardingPopupsCtl extends Disposable { }, { name: 'offset', options: { - offset: [-12, 12], + offset: [0, 12 - adjacentPadding], } }], }); + } + + private _addFocusLayer(container: HTMLElement) { + dom.autoDisposeElem(container, new FocusLayer({ + defaultFocusElem: container, + allowFocus: (elem) => (elem !== document.body) + })); + } + + // Get the padding length for the side that will be next to the popup. + private _getAdjacentPadding(elem: HTMLElement, placement?: Placement) { + if (placement) { + let padding = ''; + if (placement.includes('bottom')) { + padding = getComputedStyle(elem).paddingBottom; + } + else if (placement.includes('top')) { + padding = getComputedStyle(elem).paddingTop; + } + else if (placement.includes('left')) { + padding = getComputedStyle(elem).paddingLeft; + } + else if (placement.includes('right')) { + padding = getComputedStyle(elem).paddingRight; + } + // Note: getComputedStyle return value in pixel, hence no need to handle other unit. See here + // for reference: + // https://developer.mozilla.org/en-US/docs/Web/API/Window/getComputedStyle#notes. + if (padding && padding.endsWith('px')) { + return Number(padding.slice(0, padding.length - 2)); + } + } + return 0; + } + + private _showHasModal() { + const content = this._buildPopupContent(); + dom.update(this._overlay, content); + this._addFocusLayer(content); + + function close() { + content.remove(); + dom.domDispose(content); + } + this._openPopupCtl = {close}; } private _buildPopupContent() { - return Container(this._arrowEl, ContentWrapper( - this._messages[this._index].buildDom(), + const container = Container({tabindex: '-1'}, this._arrowEl, ContentWrapper( + cssTitle(this._messages[this._index].title), + cssBody(this._messages[this._index].body), this._buildFooter(), testId('popup'), )); + return container; } private _buildFooter() { @@ -186,12 +251,13 @@ class OnBoardingPopupsCtl extends Disposable { function buildArrow() { return ArrowContainer( - svg('svg', { style: 'width: 14px; height: 36px;' }, - svg('path', {'d': 'M 2 16 h 12 v 16 Z'})) + svg('svg', { style: 'width: 13px; height: 34px;' }, + svg('path', {'d': 'M 2 19 h 13 v 18 Z'})) ); } const Container = styled('div', ` + align-self: center; border: 2px solid ${colors.lightGreen}; border-radius: 3px; z-index: 1000; @@ -199,6 +265,7 @@ const Container = styled('div', ` position: relative; background-color: white; box-shadow: 0 2px 18px 0 rgba(31,37,50,0.31), 0 0 1px 0 rgba(76,86,103,0.24); + outline: unset; `); function sideSelectorChunk(side: 'top'|'bottom'|'left'|'right') { @@ -219,7 +286,7 @@ const ArrowContainer = styled('div', ` } ${sideSelectorChunk('bottom')} > & { - top: -24px; + top: -23px; } ${sideSelectorChunk('right')} > & { @@ -282,6 +349,7 @@ const Overlay = styled('div', ` position: fixed; display: flex; flex-direction: column; + justify-content: center; width: 100%; height: 100%; top: 0; @@ -289,3 +357,14 @@ const Overlay = styled('div', ` z-index: 999; overflow-y: auto; `); + +const cssTitle = styled('div', ` + font-size: ${vars.xxxlargeFontSize}; + font-weight: ${vars.headerControlTextWeight}; + color: ${colors.dark}; + margin: 0 0 16px 0; + line-height: 32px; +`); + +const cssBody = styled('div', ` +`); diff --git a/app/client/ui/PagePanels.ts b/app/client/ui/PagePanels.ts index 208410fb..81a12aa8 100644 --- a/app/client/ui/PagePanels.ts +++ b/app/client/ui/PagePanels.ts @@ -106,6 +106,7 @@ export function pagePanels(page: PageContents) { (!right || right.hideOpener ? null : cssPanelOpener('PanelLeft', cssPanelOpener.cls('-open', right.panelOpen), testId('right-opener'), + dom.cls('tour-creator-panel'), dom.on('click', () => toggleObs(right.panelOpen)), cssHideForNarrowScreen.cls('')) ), diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts index 3c68e3b3..9b51cc2b 100644 --- a/app/client/ui/ShareMenu.ts +++ b/app/client/ui/ShareMenu.ts @@ -88,7 +88,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc, if (!buttonText) { // Regular circular button that opens a menu. return cssHoverCircle({ style: `margin: 5px;` }, - cssTopBarBtn('Share'), + cssTopBarBtn('Share', dom.cls('tour-share-icon')), menu(menuCreateFunc, {placement: 'bottom-end'}), testId('tb-share'), ); @@ -318,4 +318,3 @@ const cssMenuIconLink = styled('a', ` const cssMenuIcon = styled(icon, ` display: block; `); - diff --git a/app/client/ui/welcomeTour.ts b/app/client/ui/welcomeTour.ts new file mode 100644 index 00000000..13944126 --- /dev/null +++ b/app/client/ui/welcomeTour.ts @@ -0,0 +1,112 @@ +import { IOnBoardingMsg, startOnBoarding } from "app/client/ui/OnBoardingPopups"; +import { colors } from 'app/client/ui2018/cssVars'; +import { icon } from "app/client/ui2018/icons"; +import { dom, styled } from "grainjs"; + +export const welcomeTour: IOnBoardingMsg[] = [ + { + title: 'Editing Data', + body: () => [ + dom('p', + 'Double-click or hit ', Key(KeyContent('Enter')), ' on a cell to edit it. ', + 'Start with ', Key(KeyStrong('=')), ' to enter a formula.' + ) + ], + selector: '.field_clip', + placement: 'bottom', + }, + { + selector: '.tour-creator-panel', + title: 'Configuring your document', + body: () => [ + dom('p', + 'Toggle the ', dom('em', 'creator panel'), ' to format columns, ', + 'convert to card view, select data, and more.' + ) + ], + placement: 'left', + cropPadding: true, + }, + { + selector: '.tour-type-selector', + title: 'Customizing columns', + body: () => [ + dom('p', + 'Set formatting options, formulas, or column types, such as dates, choices, or attachments. '), + dom('p', + 'Make it relational! Use the ', Key('Reference'), ' type to link tables. ' + ) + ], + placement: 'right', + }, + { + selector: '.tour-add-new', + title: 'Building up', + body: () => [ + dom('p', 'Use ', Key('Add New'), ' to add widgets, pages, or import more data. ') + ], + placement: 'right', + }, + { + selector: '.tour-share-icon', + title: 'Sharing', + body: () => [ + dom('p', 'Use the Share button (', Icon('Share'), ') to share the document or export data.') + ], + placement: 'bottom', + cropPadding: true, + }, + { + selector: '.tour-help-center', + title: 'Keep learning', + 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. '), + ], + placement: 'right', + }, + { + selector: '.tour-welcome', + title: 'Welcome to Grist!', + showHasModal: true, + } + +]; + +export function startWelcomeTour(onFinishCB: () => void) { + startOnBoarding(welcomeTour, onFinishCB); +} + +const KeyContent = styled('span', ` + font-style: normal; + font-family: inherit; + color: ${colors.darkGreen}; +`); + +const KeyStrong = styled(KeyContent, ` + font-weight: 700; +`); + +const Key = styled('code', ` + padding: 2px 5px; + border-radius: 4px; + margin: 0px 2px; + border: 1px solid ${colors.slate}; + color: black; + background-color: white; + font-family: inherit; + font-style: normal; + white-space: nowrap; +`); + +const Icon = styled(icon, ` + --icon-color: ${colors.lightGreen}; +`); diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index d6f07d13..6e1e30bf 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -217,7 +217,8 @@ export class FieldBuilder extends Disposable { disabled: (use) => use(this._isTransformingFormula) || use(this.origColumn.disableModifyBase) || use(this.isCallPending) }), - testId('type-select') + testId('type-select'), + grainjsDom.cls('tour-type-selector'), ), grainjsDom.maybe((use) => use(this._isRef) && !use(this._isTransformingType), () => this._buildRefTableSelect()), grainjsDom.maybe(this._isTransformingType, () => { diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 6e0bb9ed..8ec9f732 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -60,6 +60,7 @@ export interface IGristUrlState { newui?: boolean; billing?: BillingPage; welcome?: WelcomePage; + welcomeTour?: boolean; params?: { billingPlan?: string; billingTask?: BillingTask; @@ -290,12 +291,15 @@ 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; + } } return state; }