gristlabs_grist-core/app/client/ui/OnBoardingPopups.ts
George Gevoian cd339ce7cb (core) Forms post-release fixes and improvements
Summary:
Fixes misc. bugs with forms, updates Grist URLs on static form pages to link
to the new forms marketing page, and adds a forms announcement popup that's
shown next to the Add New button within a document.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4185
2024-02-14 16:38:16 -05:00

457 lines
13 KiB
TypeScript

/**
* Utility to generate a series of onboarding popups. It is used to give users a short description
* of some elements of the UI. The first step is to create the list of messages following the
* `IOnBoardingMsg` interface. Then you have to attach each message to its corresponding element of
* the UI using the `attachOnBoardingMsg' dom method:
*
* Usage:
*
* // create the list of message
* const messages = [{id: 'add-new-btn', placement: 'right', buildDom: () => ... },
* {id: 'share-btn', buildDom: () => ... ];
*
*
* // attach each message to the corresponding element
* dom('div', 'Add New', ..., dom.cls('tour-add-new-btn'));
*
* // start
* startOnBoarding(message, onFinishCB);
*
* Note:
* - this module does UI only, saving which user has already seen the popups has to be handled by
* the caller. Pass an `onFinishCB` to handle when a user dimiss the popups.
*/
import { Disposable, dom, DomElementArg, Holder, makeTestId, Observable, styled, svg } from "grainjs";
import { createPopper, Placement } from '@popperjs/core';
import { FocusLayer } from 'app/client/lib/FocusLayer';
import {makeT} from 'app/client/lib/localization';
import * as Mousetrap from 'app/client/lib/Mousetrap';
import { bigBasicButton, bigPrimaryButton } from "app/client/ui2018/buttons";
import { theme, 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";
import {cssBigIcon, cssCloseButton} from "./ExampleCard";
const t = makeT('OnBoardingPopups');
const testId = makeTestId('test-onboarding-');
// Describes an onboarding popup. Each popup is uniquely identified by its id.
export interface IOnBoardingMsg {
// A CSS selector pointing to the reference element
selector: string,
// 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;
// If present, will be passed to urlState().pushUrl() to navigate to the location defined by that state
urlState?: IGristUrlState;
}
let _isTourActiveObs: Observable<boolean>|undefined;
// Returns a singleton observable for whether some tour is currently active.
//
// GristDoc subscribes to this observable in order to temporarily disable tips and other
// in-product popups from being shown while a tour is active.
export function isTourActiveObs(): Observable<boolean> {
if (!_isTourActiveObs) {
const obs = Observable.create<boolean>(null, false);
_isTourActiveObs = obs;
}
return _isTourActiveObs;
}
// 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<OnBoardingPopupsCtl>(null);
export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: (lastMessageIndex: number) => void) {
const ctl = OnBoardingPopupsCtl.create(tourSingleton, messages, onFinishCB);
ctl.onDispose(() => isTourActiveObs().set(false));
ctl.start().catch(reportError);
isTourActiveObs().set(true);
}
// Returns whether some tour is currently active.
export function isTourActive(): boolean {
return isTourActiveObs().get();
}
class OnBoardingError extends Error {
public name = 'OnBoardingError';
constructor(message: string) {
super(message);
}
}
/**
* Current index in the list of messages.
* This allows closing the tour and reopening where you left off.
* Since it's a single global value, mixing unrelated tours
* (e.g. the generic welcome tour and a specific document tour)
* in a single page load won't work well.
*/
let ctlIndex = 0;
class OnBoardingPopupsCtl extends Disposable {
private _openPopupCtl: {close: () => void}|undefined;
private _overlay: HTMLElement;
private _arrowEl = buildArrow();
constructor(private _messages: IOnBoardingMsg[], private _onFinishCB: (lastMessageIndex: number) => void) {
super();
if (this._messages.length === 0) {
throw new OnBoardingError('messages should not be an empty list');
}
// In case we're reopening after deleting some rows of GristDocTour,
// ensure ctlIndex is still within bounds
ctlIndex = Math.min(ctlIndex, this._messages.length - 1);
this.onDispose(() => {
this._openPopupCtl?.close();
});
}
public async start() {
this._showOverlay();
await this._move(0);
Mousetrap.setPaused(true);
this.onDispose(() => {
Mousetrap.setPaused(false);
});
}
private _finish(lastMessageIndex: number) {
this._onFinishCB(lastMessageIndex);
this.dispose();
}
private async _move(movement: number, maybeClose = false) {
const newIndex = ctlIndex + movement;
const entry = this._messages[newIndex];
if (!entry) {
if (maybeClose) {
this._finish(ctlIndex);
// User finished the tour, close and restart from the beginning if they reopen
ctlIndex = 0;
}
return; // gone out of bounds, probably by keyboard shortcut
}
ctlIndex = newIndex;
if (entry.skip) {
// movement = 0 when starting a tour, make sure we don't get stuck in a loop
await this._move(movement || +1);
return;
}
// 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 {
await this._showHasPopup(movement);
}
}
private async _showHasPopup(movement: number) {
const content = this._buildPopupContent();
const entry = this._messages[ctlIndex];
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!`);
// movement = 0 when starting a tour, make sure we don't get stuck in a loop
return this._move(movement || +1);
}
// Cleanup
function close() {
popper.destroy();
dom.domDispose(content);
content.remove();
}
this._openPopupCtl = {close};
document.body.appendChild(content);
this._addFocusLayer(content);
// Create a popper for positioning the popup content relative to the reference element
const adjacentPadding = entry.cropPadding ? this._getAdjacentPadding(elem, placement) : 0;
const popper = createPopper(elem, content, {
placement,
modifiers: [{
name: 'arrow',
options: {
element: this._arrowEl,
},
}, {
name: 'offset',
options: {
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(
{tabindex: '-1'},
this._arrowEl,
ContentWrapper(
cssCloseButton(cssBigIcon('CrossBig'),
dom.on('click', () => this._finish(ctlIndex)),
testId('close'),
),
cssTitle(this._messages[ctlIndex].title),
cssBody(this._messages[ctlIndex].body),
this._buildFooter(),
testId('popup'),
),
dom.onKeyDown({
Escape: () => this._finish(ctlIndex),
ArrowLeft: () => this._move(-1),
ArrowRight: () => this._move(+1),
Enter: () => this._move(+1, true),
}),
);
}
private _buildFooter() {
const nSteps = this._messages.length;
const isLastStep = ctlIndex === nSteps - 1;
const isFirstStep = ctlIndex === 0;
return Footer(
ProgressBar(
range(nSteps).map((i) => Dot(Dot.cls('-done', i > ctlIndex))),
),
Buttons(
bigBasicButton(
'Previous', testId('previous'),
dom.on('click', () => this._move(-1)),
dom.prop('disabled', isFirstStep),
{style: `margin-right: 8px; visibility: ${isFirstStep ? 'hidden' : 'visible'}`},
),
bigPrimaryButton(
isLastStep ? t("Finish") : t("Next"), testId('next'),
dom.on('click', () => this._move(+1, true)),
),
)
);
}
private _showOverlay() {
document.body.appendChild(this._overlay = Overlay());
this.onDispose(() => {
document.body.removeChild(this._overlay);
dom.domDispose(this._overlay);
});
}
}
function buildArrow() {
return ArrowContainer(
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 ${theme.accentBorder};
border-radius: 3px;
z-index: ${vars.onboardingPopupZIndex};
max-width: 490px;
position: relative;
background-color: ${theme.popupBg};
box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow};
outline: unset;
`);
function sideSelectorChunk(side: 'top'|'bottom'|'left'|'right') {
return `.${Container.className}[data-popper-placement^=${side}]`;
}
const ArrowContainer = styled('div', `
position: absolute;
& path {
stroke: ${theme.accentBorder};
stroke-width: 2px;
fill: ${theme.popupBg};
}
${sideSelectorChunk('top')} > & {
bottom: -26px;
}
${sideSelectorChunk('bottom')} > & {
top: -23px;
}
${sideSelectorChunk('right')} > & {
left: -12px;
}
${sideSelectorChunk('left')} > & {
right: -12px;
}
${sideSelectorChunk('top')} svg {
transform: rotate(-90deg);
}
${sideSelectorChunk('bottom')} svg {
transform: rotate(90deg);
}
${sideSelectorChunk('left')} svg {
transform: scalex(-1);
}
`);
const ContentWrapper = styled('div', `
position: relative;
padding: 32px;
background-color: ${theme.popupBg};
`);
const Footer = styled('div', `
display: flex;
flex-direction: row;
margin-top: 32px;
justify-content: space-between;
align-items: center;
`);
const ProgressBar = styled('div', `
display: flex;
flex-direction: row;
flex-wrap: wrap;
row-gap: 12px;
`);
const Buttons = styled('div', `
display: flex;
flex-directions: row;
`);
const Dot = styled('div', `
width: 6px;
height: 6px;
border-radius: 3px;
margin-right: 12px;
align-self: center;
background-color: ${theme.progressBarFg};
&-done {
background-color: ${theme.progressBarBg};
}
`);
const Overlay = styled('div', `
position: fixed;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: ${vars.onboardingBackdropZIndex};
overflow-y: auto;
`);
const cssTitle = styled('div', `
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
color: ${theme.text};
margin: 0 0 16px 0;
line-height: 32px;
`);
const cssBody = styled('div', `
color: ${theme.text};
`);