diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 278fb865..08e41337 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -63,7 +63,7 @@ import { Drafts } from "app/client/components/Drafts"; const G = getBrowserGlobals('document', 'window'); // Re-export some tools to move them from main webpack bundle to the one with GristDoc. -export {DocComm, showDocSettingsModal}; +export {DocComm, showDocSettingsModal, startDocTour}; export interface TabContent { showObs?: any; diff --git a/app/client/ui/ExampleCard.ts b/app/client/ui/ExampleCard.ts index 15d114a7..9be8183a 100644 --- a/app/client/ui/ExampleCard.ts +++ b/app/client/ui/ExampleCard.ts @@ -1,44 +1,24 @@ -import {AppModel} from 'app/client/models/AppModel'; -import {getUserOrgPrefObs} from 'app/client/models/UserPrefs'; import {IExampleInfo} from 'app/client/ui/ExampleInfo'; import {prepareForTransition, TransitionWatcher} from 'app/client/ui/transitions'; import {colors, mediaXSmall, testId, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; -import {dom, Observable, styled} from 'grainjs'; +import {dom, styled} from 'grainjs'; +import {AutomaticHelpToolInfo} from "app/client/ui/Tools"; let prevCardClose: (() => void)|null = null; // Open a popup with a card introducing this example, if the user hasn't dismissed it in the past. export function showExampleCard( - example: IExampleInfo, appModel: AppModel, btnElem: HTMLElement, reopen: boolean = false + example: IExampleInfo, toolInfo: AutomaticHelpToolInfo ) { - const prefObs: Observable = getUserOrgPrefObs(appModel, 'seenExamples'); - const seenExamples = prefObs.get() || []; - - // If this example was previously dismissed, don't show the card, unless the user is reopening it. - if (!reopen && seenExamples.includes(example.id)) { - return; - } - - // When an example card is closed, if it's the first time it's dismissed, save this fact, to avoid - // opening the card again in the future. - async function markAsSeen() { - if (!seenExamples.includes(example.id)) { - const seen = new Set(seenExamples); - seen.add(example.id); - prefObs.set([...seen].sort()); - } - } + const {elem: btnElem, markAsSeen, reopen} = toolInfo; // Close the example card. function close() { prevCardClose = null; collapseAndRemoveCard(cardElem, btnElem.getBoundingClientRect()); - // If we fail to save this preference, it's probably not worth alerting the user about, - // so just log to console. - // tslint:disable-next-line:no-console - markAsSeen().catch((err) => console.warn("Failed to save userPref", err)); + markAsSeen(); } const card = example.welcomeCard; diff --git a/app/client/ui/Tools.ts b/app/client/ui/Tools.ts index 929cd758..98cd19ac 100644 --- a/app/client/ui/Tools.ts +++ b/app/client/ui/Tools.ts @@ -11,6 +11,8 @@ import { cssLink } from 'app/client/ui2018/links'; import { menuAnnotate } from 'app/client/ui2018/menus'; import { userOverrideParams } from 'app/common/gristUrls'; import { Disposable, dom, makeTestId, Observable, observable, styled } from "grainjs"; +import {getUserOrgPrefObs} from "app/client/models/UserPrefs"; +import {loadGristDoc} from "app/client/lib/imports"; const testId = makeTestId('test-tools-'); @@ -67,29 +69,121 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse if (!doc.workspace.isSupportWorkspace) { return null; } const ex = examples.find((e) => e.matcher.test(doc.name)); if (!ex || !ex.tutorialUrl) { return null; } - const appModel = gristDoc.docPageModel.appModel; return cssPageEntry( cssPageLink(cssPageIcon('Page'), cssLinkText('How-to Tutorial'), testId('tutorial'), {href: ex.tutorialUrl, target: '_blank'}, cssExampleCardOpener( icon('TypeDetails'), - dom.on('click', (ev, elem) => { - ev.preventDefault(); - showExampleCard(ex, appModel, elem, true); - }), testId('welcome-opener'), - (elem) => { - // Once the trigger element is attached to DOM, show the card. - setTimeout(() => showExampleCard(ex, appModel, elem), 0); - }, + automaticHelpTool( + (info) => showExampleCard(ex, info), + gristDoc, + "seenExamples", + ex.id + ), ), ), ); }), + // Shows the 'Tour of this Document' button if a GristDocTour table exists + // at the time of running. Currently doesn't observe the set of existing tables + gristDoc.docData.getTable('GristDocTour') && + cssPageEntry( + cssPageLink( + cssPageIcon('Page'), + cssLinkText('Tour of this Document'), + testId('doctour'), + automaticHelpTool( + async ({markAsSeen}) => { + const gristDocModule = await loadGristDoc(); + await gristDocModule.startDocTour(gristDoc.docData, markAsSeen); + }, + gristDoc, + "seenDocTours", + gristDoc.docId() + ), + ), + ), createHelpTools(gristDoc.docPageModel.appModel, false) ); } +/** + * Helper for showing users some kind of help (example cards or document tours) + * automatically if they haven't seen it before, or if they click + * on some element to explicitly show it again. Put this in said dom element, + * and it will provide the onclick handler and a handler which automatically + * shows when the dom element is attached, both by calling showFunc. + * + * prefKey is a key for a list of identifiers saved in user preferences. + * itemId should be a single identifier that fits in that list. + * If itemId is already present then the help will not be shown automatically, + * otherwise it will be added to the list and saved under prefKey + * when info.markAsSeen() is called. + */ +function automaticHelpTool( + showFunc: (info: AutomaticHelpToolInfo) => void, + gristDoc: GristDoc, + prefKey: 'seenExamples' | 'seenDocTours', + itemId: number | string +) { + function show(elem: HTMLElement, reopen: boolean) { + const appModel = gristDoc.docPageModel.appModel; + const prefObs: Observable = getUserOrgPrefObs(appModel, prefKey); + const seenIds = prefObs.get() || []; + + // If this help was previously dismissed, don't show it again, unless the user is reopening it. + if (!reopen && seenIds.includes(itemId)) { + return; + } + + // When the help is closed, if it's the first time it's dismissed, save this fact, to avoid + // showing it automatically again in the future. + function markAsSeen() { + try { + if (!seenIds.includes(itemId)) { + const seen = new Set(seenIds); + seen.add(itemId); + prefObs.set([...seen].sort()); + } + } catch (e) { + // If we fail to save this preference, it's probably not worth alerting the user about, + // so just log to console. + // tslint:disable-next-line:no-console + console.warn("Failed to save userPref " + prefKey, e); + } + } + + showFunc({elem, reopen, markAsSeen}); + } + + return [ + dom.on('click', (ev, elem) => { + ev.preventDefault(); + show(elem as HTMLElement, true); + }), + (elem: HTMLElement) => { + // Once the trigger element is attached to DOM, show the help + setTimeout(() => show(elem, false), 0); + } + ]; +} + +/** Values which may be useful when showing an automatic help tool */ +export interface AutomaticHelpToolInfo { + // Element where automaticHelpTool is attached, typically a button, + // which shows the help when clicked + elem: HTMLElement; + + // true if the help was shown explicitly by clicking elem, + // false if it's being shown automatically to new users + reopen: boolean; + + // Call this when the user explicitly dismisses the help to + // remember this in user preferences and not show it automatically on next load + markAsSeen: () => void; +} + // When viewing a page as another user, the "Access Rules" page link includes a button to revert // the user and open the page, and a click on the page link shows a tooltip to revert. function addRevertViewAsUI() { diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts index 1405f6f9..89a3f3f2 100644 --- a/app/common/Prefs.ts +++ b/app/common/Prefs.ts @@ -23,6 +23,9 @@ export interface UserOrgPrefs extends Prefs { // The numbers are the `id` from IExampleInfo in app/client/ui/ExampleInfo. // By living in UserOrgPrefs, this applies only to the examples-containing org. seenExamples?: number[]; + + // List of document IDs where the user has seen and dismissed the document tour. + seenDocTours?: string[]; } export type OrgPrefs = Prefs;