mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
04e5d90f86
commit
15f1ef96fa
@ -35,6 +35,7 @@ import {showDocSettingsModal} from 'app/client/ui/DocumentSettings';
|
|||||||
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
||||||
import {IPageWidgetLink, linkFromId, selectBy} from 'app/client/ui/selectBy';
|
import {IPageWidgetLink, linkFromId, selectBy} from 'app/client/ui/selectBy';
|
||||||
import {startWelcomeTour} from 'app/client/ui/welcomeTour';
|
import {startWelcomeTour} from 'app/client/ui/welcomeTour';
|
||||||
|
import {startDocTour} from "app/client/ui/DocTour";
|
||||||
import {mediaSmall, testId} from 'app/client/ui2018/cssVars';
|
import {mediaSmall, testId} from 'app/client/ui2018/cssVars';
|
||||||
import {IconName} from 'app/client/ui2018/IconList';
|
import {IconName} from 'app/client/ui2018/IconList';
|
||||||
import {ActionGroup} from 'app/common/ActionGroup';
|
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.
|
// Start welcome tour if flag is present in the url hash.
|
||||||
this.autoDispose(subscribe(urlState().state, async (_use, state) => {
|
this.autoDispose(subscribe(urlState().state, async (_use, state) => {
|
||||||
if (state.welcomeTour) {
|
if (state.welcomeTour || state.docTour) {
|
||||||
await this._waitForView();
|
await this._waitForView();
|
||||||
await delay(0); // we need to wait an extra bit.
|
await delay(0); // we need to wait an extra bit.
|
||||||
// TODO:
|
// TODO:
|
||||||
@ -207,7 +208,11 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
// of the messages relates to that part of the UI.
|
// 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
|
// 3) On boarding tours were not designed with mobile support in mind. So probably a
|
||||||
// good idea to disable.
|
// good idea to disable.
|
||||||
startWelcomeTour(() => null);
|
if (state.welcomeTour) {
|
||||||
|
startWelcomeTour(() => null);
|
||||||
|
} else {
|
||||||
|
await startDocTour(this.docData, () => null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -27,6 +27,7 @@ import {UrlState} from 'app/client/lib/UrlState';
|
|||||||
import {decodeUrl, encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState, useNewUI} from 'app/common/gristUrls';
|
import {decodeUrl, encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState, useNewUI} from 'app/common/gristUrls';
|
||||||
import {Document} from 'app/common/UserAPI';
|
import {Document} from 'app/common/UserAPI';
|
||||||
import isEmpty = require('lodash/isEmpty');
|
import isEmpty = require('lodash/isEmpty');
|
||||||
|
import {CellValue} from "app/plugin/GristData";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a singleton UrlState object, initializing it on first use.
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
62
app/client/ui/DocTour.ts
Normal file
62
app/client/ui/DocTour.ts
Normal 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;
|
||||||
|
}
|
@ -29,6 +29,10 @@ import * as Mousetrap from 'app/client/lib/Mousetrap';
|
|||||||
import { bigBasicButton, bigPrimaryButton } from "app/client/ui2018/buttons";
|
import { bigBasicButton, bigPrimaryButton } from "app/client/ui2018/buttons";
|
||||||
import { colors, vars } from "app/client/ui2018/cssVars";
|
import { colors, vars } from "app/client/ui2018/cssVars";
|
||||||
import range = require("lodash/range");
|
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-');
|
const testId = makeTestId('test-onboarding-');
|
||||||
|
|
||||||
@ -61,11 +65,14 @@ export interface IOnBoardingMsg {
|
|||||||
|
|
||||||
// Skip the message
|
// Skip the message
|
||||||
skip?: boolean;
|
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) {
|
export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: () => void) {
|
||||||
const ctl = new OnBoardingPopupsCtl(messages, onFinishCB);
|
const ctl = new OnBoardingPopupsCtl(messages, onFinishCB);
|
||||||
ctl.start();
|
ctl.start().catch(reportError);
|
||||||
}
|
}
|
||||||
|
|
||||||
class OnBoardingError extends Error {
|
class OnBoardingError extends Error {
|
||||||
@ -91,9 +98,9 @@ class OnBoardingPopupsCtl extends Disposable {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public start(): void {
|
public async start() {
|
||||||
this._showOverlay();
|
this._showOverlay();
|
||||||
this._next();
|
await this._next();
|
||||||
Mousetrap.setPaused(true);
|
Mousetrap.setPaused(true);
|
||||||
this.onDispose(() => {
|
this.onDispose(() => {
|
||||||
Mousetrap.setPaused(false);
|
Mousetrap.setPaused(false);
|
||||||
@ -105,22 +112,27 @@ class OnBoardingPopupsCtl extends Disposable {
|
|||||||
this.dispose();
|
this.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _next(): void {
|
private async _next() {
|
||||||
this._index = this._index + 1;
|
this._index = this._index + 1;
|
||||||
const entry = this._messages[this._index];
|
const entry = this._messages[this._index];
|
||||||
if (entry.skip) { this._next(); }
|
if (entry.skip) { await this._next(); }
|
||||||
|
|
||||||
// close opened popup if any
|
// close opened popup if any
|
||||||
this._openPopupCtl?.close();
|
this._openPopupCtl?.close();
|
||||||
|
|
||||||
|
if (entry.urlState) {
|
||||||
|
await urlState().pushUrl(entry.urlState);
|
||||||
|
await delay(100); // make sure cursor is in correct place
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.showHasModal) {
|
if (entry.showHasModal) {
|
||||||
this._showHasModal();
|
this._showHasModal();
|
||||||
} else {
|
} else {
|
||||||
this._showHasPopup();
|
await this._showHasPopup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _showHasPopup() {
|
private async _showHasPopup() {
|
||||||
const content = this._buildPopupContent();
|
const content = this._buildPopupContent();
|
||||||
const entry = this._messages[this._index];
|
const entry = this._messages[this._index];
|
||||||
const elem = document.querySelector<HTMLElement>(entry.selector);
|
const elem = document.querySelector<HTMLElement>(entry.selector);
|
||||||
|
@ -5,7 +5,7 @@ import {colors, testId} from 'app/client/ui2018/cssVars';
|
|||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {NTextBox} from 'app/client/widgets/NTextBox';
|
import {NTextBox} from 'app/client/widgets/NTextBox';
|
||||||
import {dom, styled} from 'grainjs';
|
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.
|
* 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.cls('text_wrapping', this.wrapping),
|
||||||
dom.maybe((use) => Boolean(use(value)), () =>
|
dom.maybe((use) => Boolean(use(value)), () =>
|
||||||
dom('a',
|
dom('a',
|
||||||
dom.attr('href', (use) => _constructUrl(use(value))),
|
dom.attr('href', (use) => constructUrl(use(value))),
|
||||||
dom.attr('target', '_blank'),
|
dom.attr('target', '_blank'),
|
||||||
dom.on('click', (ev) =>
|
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
|
// As per Google and Mozilla recommendations to prevent opened links
|
||||||
// from running on the same process as Grist:
|
// from running on the same process as Grist:
|
||||||
// https://developers.google.com/web/tools/lighthouse/audits/noopener
|
// 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;
|
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)
|
* If possible (i.e. if `url` points to somewhere in the current document)
|
||||||
* use pushUrl to navigate without reloading or opening a new tab
|
* 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.
|
// Only override plain-vanilla clicks.
|
||||||
if (ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) { return; }
|
if (ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) { return; }
|
||||||
|
|
||||||
const oldOrigin = window.location.origin;
|
const newUrlState = sameDocumentUrlState(url);
|
||||||
const newOrigin = url.origin;
|
if (!newUrlState) { return; }
|
||||||
if (oldOrigin !== newOrigin) { return; }
|
|
||||||
|
|
||||||
const urlStateImpl = new UrlStateImpl(window as any);
|
|
||||||
const newUrlState = urlStateImpl.decodeUrl(url);
|
|
||||||
if (urlStateImpl.needPageLoad(urlState().state.get(), newUrlState)) { return; }
|
|
||||||
|
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
await urlState().pushUrl(newUrlState);
|
await urlState().pushUrl(newUrlState);
|
||||||
|
@ -61,6 +61,7 @@ export interface IGristUrlState {
|
|||||||
billing?: BillingPage;
|
billing?: BillingPage;
|
||||||
welcome?: WelcomePage;
|
welcome?: WelcomePage;
|
||||||
welcomeTour?: boolean;
|
welcomeTour?: boolean;
|
||||||
|
docTour?: boolean;
|
||||||
params?: {
|
params?: {
|
||||||
billingPlan?: string;
|
billingPlan?: string;
|
||||||
billingTask?: BillingTask;
|
billingTask?: BillingTask;
|
||||||
@ -291,15 +292,14 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
|
|||||||
}
|
}
|
||||||
if (hashMap.has('#') && hashMap.get('#') === 'a1') {
|
if (hashMap.has('#') && hashMap.get('#') === 'a1') {
|
||||||
const link: HashLink = {};
|
const link: HashLink = {};
|
||||||
for (const key of ['sectionId', 'rowId', 'colRef'] as Array<keyof Omit<HashLink, 'welcomeTour'>>) {
|
for (const key of ['sectionId', 'rowId', 'colRef'] as Array<keyof Omit<HashLink, 'welcomeTour' | 'docTour'>>) {
|
||||||
const ch = key.substr(0, 1);
|
const ch = key.substr(0, 1);
|
||||||
if (hashMap.has(ch)) { link[key] = parseInt(hashMap.get(ch)!, 10); }
|
if (hashMap.has(ch)) { link[key] = parseInt(hashMap.get(ch)!, 10); }
|
||||||
}
|
}
|
||||||
state.hash = link;
|
state.hash = link;
|
||||||
}
|
}
|
||||||
if (hashMap.has('#') && hashMap.get('#') === 'repeat-welcome-tour') {
|
state.welcomeTour = hashMap.get('#') === 'repeat-welcome-tour';
|
||||||
state.welcomeTour = true;
|
state.docTour = hashMap.get('#') === 'repeat-doc-tour';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user