(core) Allow creating custom document tours with a special table

Summary:
Like the welcome tour, a special URL hash triggers startDocTour which uses data from a table GristDocTour to construct the appropriate popups.

This is the basic version described in https://grist.quip.com/sN2RAHI2dchm/Document-tours

Test Plan:
Added a new nbrowser test which tests the data produced by makeDocTour. The general behaviour of the UI and popups has hardly changed so existing tests cover that well enough.

The new test uses a new fixture document which you can open to easily experience the tour.

Error cases where there's no valid document tour are not tested because that behaviour is likely to change significantly and this feature is still quite 'private'.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: jarek, dsagal

Differential Revision: https://phab.getgrist.com/D2938
This commit is contained in:
Alex Hall
2021-07-23 18:24:17 +02:00
parent 04e5d90f86
commit 15f1ef96fa
6 changed files with 145 additions and 40 deletions

62
app/client/ui/DocTour.ts Normal file
View File

@@ -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<IOnBoardingMsg[] | null> {
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;
}

View File

@@ -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<HTMLElement>(entry.selector);