(core) Brings welcome tour and hide behind a flag

Summary:
This diff brings in the new welcome tour. It builds upon `client/ui/OnBoardingPopup` that was committed to that purposes. Per this diff,  the tour is accessible behind a flag and won't be visible to user: few caveats listed below needs to be adressed first.

This diff also brings few changes to onboarding module.
  - allow to refer to element with selector
     - usually dynamic selection of element sounds useful for when the
     element does not exist yet when the tour starts. But the actual
     reason when add it here, is to allow selecting the first cell.
     - if the selector yields undefined (missing element), the popup
     is simply skipped
  - got rid of the internal registry to link between popup contents
  and popup options. All is now define in the same interface. Registry
  overall felt overkill and not needed.
  - adds an option to show message as a simple modal that is centered
  on the screen

This diff also brings the new welcome tour and hide it behind a flag

CAVEATS that need to be addressed in follow up commit:
 - The 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.
 - For now you have to manually make sure the right panel is opened with the Column tab selected before starting the tour.
  - On boarding tours were not designed with mobile support in mind. So probably a good idea to disable.
  - Backend support needs to be done (persistence of first time user).

Test Plan:
Updated `projects/OnBoardingPopup` and adds new `nbrowser/welcomeTour`
To launch the tour:
  - open any document
  - open manually the right panel and the field tab
  - append the flag `#repeat-welcome-tour` at the end of the url in the url bar and reload the page

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2917
This commit is contained in:
Cyprien P 2021-07-19 10:49:44 +02:00
parent a9d5b4d5af
commit 693f2f6325
9 changed files with 286 additions and 66 deletions

View File

@ -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);

View File

@ -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),

View File

@ -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),
),
];

View File

@ -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<IOnboardingRegistryItem> = [];
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<HTMLElement>(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<PopperOptions> = { 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', `
`);

View File

@ -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(''))
),

View File

@ -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;
`);

View File

@ -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};
`);

View File

@ -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, () => {

View File

@ -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<GristLoadConfig>, location: Locat
}
if (hashMap.has('#') && hashMap.get('#') === 'a1') {
const link: HashLink = {};
for (const key of ['sectionId', 'rowId', 'colRef'] as Array<keyof HashLink>) {
for (const key of ['sectionId', 'rowId', 'colRef'] as Array<keyof Omit<HashLink, 'welcomeTour'>>) {
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;
}