diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index c6c31c3e..f0b2ed56 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -43,6 +43,7 @@ import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/mode import {App} from 'app/client/ui/App'; import {DocHistory} from 'app/client/ui/DocHistory'; import {startDocTour} from "app/client/ui/DocTour"; +import {DocTutorial} from 'app/client/ui/DocTutorial'; import {isTourActive} from "app/client/ui/OnBoardingPopups"; import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {linkFromId, selectBy} from 'app/client/ui/selectBy'; @@ -162,9 +163,6 @@ export class GristDoc extends DisposableWithEvents { public readonly userOrgPrefs = getUserOrgPrefsObs(this.docPageModel.appModel); - // If the doc has a docTour. Used also to enable the UI button to restart the tour. - public readonly hasDocTour: Computed; - public readonly behavioralPromptsManager = this.docPageModel.appModel.behavioralPromptsManager; // One of the section can be expanded (as requested from the Layout), we will // store its id in this variable. NOTE: expanded section looks exactly the same as a section @@ -189,6 +187,7 @@ export class GristDoc extends DisposableWithEvents { private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours'); private _rawSectionOptions: Observable = Observable.create(this, null); private _activeContent: Computed; + private _docTutorialHolder = Holder.create(this); constructor( @@ -204,7 +203,7 @@ export class GristDoc extends DisposableWithEvents { super(); console.log("RECEIVED DOC RESPONSE", openDocResponse); this.docData = new DocData(this.docComm, openDocResponse.doc); - this.docModel = new DocModel(this.docData); + this.docModel = new DocModel(this.docData, this.docPageModel); this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm); this.docPluginManager = new DocPluginManager(plugins, app.topAppModel.getUntrustedContentOrigin(), this.docComm, app.clientScope); @@ -212,9 +211,6 @@ export class GristDoc extends DisposableWithEvents { // Maintain the MetaRowModel for the global document info, including docId and peers. this.docInfo = this.docModel.docInfoRow; - this.hasDocTour = Computed.create(this, use => - use(this.docModel.visibleTableIds.getObservable()).includes('GristDocTour')); - const defaultViewId = this.docInfo.newDefaultViewId; // Grainjs observable for current view id, which may be a string such as 'code'. @@ -287,27 +283,31 @@ export class GristDoc extends DisposableWithEvents { } })); - // Start welcome tour if flag is present in the url hash. - let tourStarting = false; + let isStartingTourOrTutorial = false; this.autoDispose(subscribe(urlState().state, async (_use, state) => { - // Onboarding tours were not designed with mobile support in mind. Disable until fixed. - if (isNarrowScreen()) { + // Only start a tour or tutorial when the full interface is showing, i.e. not when in + // embedded mode. + if (state.params?.style === 'light') { return; } - // Only start a tour when the full interface is showing, i.e. not when in embedded mode. - if (state.params?.style === 'light') { + + const shouldStartTutorial = this.docModel.isTutorial(); + // Onboarding tours were not designed with mobile support in mind. Disable until fixed. + if (isNarrowScreen() && !shouldStartTutorial) { return; } - // If we have an active tour (or are in the process of starting one), don't start a new one. - if (tourStarting || isTourActive()) { + + // If we have an active tour or tutorial (or are in the process of starting one), don't start + // a new one. + const hasActiveTourOrTutorial = isTourActive() || !this._docTutorialHolder.isEmpty(); + if (isStartingTourOrTutorial || hasActiveTourOrTutorial) { return; } - const autoStartDocTour = this.hasDocTour.get() && !this._seenDocTours.get()?.includes(this.docId()); - const docTour = state.docTour || autoStartDocTour; - const welcomeTour = state.welcomeTour || this._shouldAutoStartWelcomeTour(); - if (welcomeTour || docTour) { - tourStarting = true; + const shouldStartDocTour = state.docTour || this._shouldAutoStartDocTour(); + const shouldStartWelcomeTour = state.welcomeTour || this._shouldAutoStartWelcomeTour(); + if (shouldStartTutorial || shouldStartDocTour || shouldStartWelcomeTour) { + isStartingTourOrTutorial = true; try { await this._waitForView(); @@ -316,14 +316,19 @@ export class GristDoc extends DisposableWithEvents { await urlState().pushUrl({welcomeTour: false, docTour: false}, {replace: true, avoidReload: true}); - if (!docTour) { - startWelcomeTour(() => this._showGristTour.set(false)); - } else { - const onFinishCB = () => (autoStartDocTour && markAsSeen(this._seenDocTours, this.docId())); + if (shouldStartTutorial) { + await DocTutorial.create(this._docTutorialHolder, this).start(); + } else if (shouldStartDocTour) { + const onFinishCB = () => ( + !this._seenDocTours.get()?.includes(this.docId()) + && markAsSeen(this._seenDocTours, this.docId()) + ); await startDocTour(this.docData, this.docComm, onFinishCB); + } else { + startWelcomeTour(() => this._showGristTour.set(false)); } } finally { - tourStarting = false; + isStartingTourOrTutorial = false; } } })); @@ -1333,12 +1338,29 @@ export class GristDoc extends DisposableWithEvents { } /** - * For first-time users on personal org, start a welcome tour. + * Returns whether a doc tour should automatically be started. + * + * Currently, tours are started if a GristDocTour table exists and the user hasn't + * seen the tour before. + */ + private _shouldAutoStartDocTour(): boolean { + if (this.docModel.isTutorial()) { + return false; + } + + return this.docModel.hasDocTour() && !this._seenDocTours.get()?.includes(this.docId()); + } + + /** + * Returns whether a welcome tour should automatically be started. + * + * Currently, tours are started for first-time users on a personal org, as long as + * a doc tutorial or tour isn't available. */ private _shouldAutoStartWelcomeTour(): boolean { - // When both a docTour and grist welcome tour are available, show only the docTour, leaving - // the welcome tour for another doc (e.g. a new one). - if (this.hasDocTour.get()) { + // If a doc tutorial or tour are available, leave the welcome tour for another + // doc (e.g. a new one). + if (this.docModel.isTutorial() || this.docModel.hasDocTour()) { return false; } diff --git a/app/client/models/DocModel.ts b/app/client/models/DocModel.ts index a4d33fb9..23ad0355 100644 --- a/app/client/models/DocModel.ts +++ b/app/client/models/DocModel.ts @@ -19,13 +19,16 @@ import * as koArray from 'app/client/lib/koArray'; import * as koUtil from 'app/client/lib/koUtil'; import DataTableModel from 'app/client/models/DataTableModel'; import {DocData} from 'app/client/models/DocData'; +import {DocPageModel} from 'app/client/models/DocPageModel'; import {urlState} from 'app/client/models/gristUrlState'; import MetaRowModel from 'app/client/models/MetaRowModel'; import MetaTableModel from 'app/client/models/MetaTableModel'; import * as rowset from 'app/client/models/rowset'; +import {TableData} from 'app/client/models/TableData'; import {isHiddenTable, isSummaryTable} from 'app/common/isHiddenTable'; import {RowFilterFunc} from 'app/common/RowFilterFunc'; import {schema, SchemaTypes} from 'app/common/schema'; +import {UIRowId} from 'app/common/UIRowId'; import {ACLRuleRec, createACLRuleRec} from 'app/client/models/entities/ACLRuleRec'; import {ColumnRec, createColumnRec} from 'app/client/models/entities/ColumnRec'; @@ -41,6 +44,7 @@ import {createViewSectionRec, ViewSectionRec} from 'app/client/models/entities/V import {CellRec, createCellRec} from 'app/client/models/entities/CellRec'; import {RefListValue} from 'app/common/gristTypes'; import {decodeObject} from 'app/plugin/objtypes'; +import { toKo } from 'grainjs'; // Re-export all the entity types available. The recommended usage is like this: // import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel'; @@ -129,10 +133,12 @@ export class DocModel { public docInfoRow: DocInfoRec; + public allTables: KoArray; public visibleTables: KoArray; public rawDataTables: KoArray; public rawSummaryTables: KoArray; + public allTableIds: KoArray; public visibleTableIds: KoArray; // A mapping from tableId to DataTableModel for user-defined tables. @@ -151,14 +157,21 @@ export class DocModel { // Flag for tracking whether document is in formula-editing mode public editingFormula: ko.Observable = ko.observable(false); + // If the doc has a docTour. Used also to enable the UI button to restart the tour. + public readonly hasDocTour: ko.Computed; + + public readonly isTutorial: ko.Computed; + // TODO This is a temporary solution until we expose creation of doc-tours to users. This flag // is initialized once on page load. If set, then the tour page (if any) will be visible. public showDocTourTable: boolean = (urlState().state.get().docPage === 'GristDocTour'); + public showDocTutorialTable: boolean = !this._docPageModel.isTutorialFork.get(); + // List of all the metadata tables. private _metaTables: Array>; - constructor(public readonly docData: DocData) { + constructor(public readonly docData: DocData, private readonly _docPageModel: DocPageModel) { // For all the metadata tables, load their data (and create the RowModels). for (const model of this._metaTables) { model.loadData(); @@ -166,13 +179,20 @@ export class DocModel { this.docInfoRow = this.docInfo.getRowModel(1); + // An observable array of all tables, sorted by tableId, with no exclusions. + this.allTables = this._createAllTablesArray(); + // An observable array of user-visible tables, sorted by tableId, excluding summary tables. // This is a publicly exposed member. - this.visibleTables = createVisibleTablesArray(this.tables); + this.visibleTables = this._createVisibleTablesArray(); // Observable arrays of raw data and summary tables, sorted by tableId. - this.rawDataTables = createRawDataTablesArray(this.tables); - this.rawSummaryTables = createRawSummaryTablesArray(this.tables); + this.rawDataTables = this._createRawDataTablesArray(); + this.rawSummaryTables = this._createRawSummaryTablesArray(); + + // An observable array of all tableIds. A shortcut mapped from allTables. + const allTableIds = ko.computed(() => this.allTables.all().map(t => t.tableId())); + this.allTableIds = koArray.syncedKoArray(allTableIds); // An observable array of user-visible tableIds. A shortcut mapped from visibleTables. const visibleTableIds = ko.computed(() => this.visibleTables.all().map(t => t.tableId())); @@ -206,6 +226,12 @@ export class DocModel { return pagesToShow.filter(p => !hide(p)); }); this.visibleDocPages = ko.computed(() => allPages.all().filter(p => !p.isHidden())); + + this.hasDocTour = ko.computed(() => this.visibleTableIds.all().includes('GristDocTour')); + + this.isTutorial = ko.computed(() => + toKo(ko, this._docPageModel.isTutorialFork)() + && this.allTableIds.all().includes('GristDocTutorial')); } private _metaTableModel>( @@ -240,6 +266,42 @@ export class DocModel { delete this.dataTables[tid]; this.dataTablesByRef.delete(tableMetaRow.getRowId()); } + + /** + * Returns an observable array of all tables, sorted by tableId. + */ + private _createAllTablesArray(): KoArray { + return createTablesArray(this.tables); + } + + /** + * Returns an observable array of user tables, sorted by tableId, and excluding hidden/summary + * tables. + */ + private _createVisibleTablesArray(): KoArray { + return createTablesArray(this.tables, r => + !isHiddenTable(this.tables.tableData, r) && + (!isTutorialTable(this.tables.tableData, r) || this.showDocTutorialTable) + ); + } + + /** + * Returns an observable array of raw data tables, sorted by tableId, and excluding summary + * tables. + */ + private _createRawDataTablesArray(): KoArray { + return createTablesArray(this.tables, r => + !isSummaryTable(this.tables.tableData, r) && + (!isTutorialTable(this.tables.tableData, r) || this.showDocTutorialTable) + ); + } + + /** + * Returns an observable array of raw summary tables, sorted by tableId. + */ + private _createRawSummaryTablesArray(): KoArray { + return createTablesArray(this.tables, r => isSummaryTable(this.tables.tableData, r)); + } } /** @@ -258,24 +320,9 @@ function createTablesArray( } /** - * Returns an observable array of user tables, sorted by tableId, and excluding hidden/summary - * tables. - */ -function createVisibleTablesArray(tablesModel: MetaTableModel): KoArray { - return createTablesArray(tablesModel, r => !isHiddenTable(tablesModel.tableData, r)); -} - -/** - * Returns an observable array of raw data tables, sorted by tableId, and excluding summary - * tables. - */ -function createRawDataTablesArray(tablesModel: MetaTableModel): KoArray { - return createTablesArray(tablesModel, r => !isSummaryTable(tablesModel.tableData, r)); -} - -/** - * Returns an observable array of raw summary tables, sorted by tableId. + * Return whether a table (identified by the rowId of its metadata record) is + * the special GristDocTutorial table. */ - function createRawSummaryTablesArray(tablesModel: MetaTableModel): KoArray { - return createTablesArray(tablesModel, r => isSummaryTable(tablesModel.tableData, r)); +function isTutorialTable(tablesData: TableData, tableRef: UIRowId): boolean { + return tablesData.getValue(tableRef, 'tableId') === 'GristDocTutorial'; } diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index f0f42c36..d6242c40 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -19,7 +19,7 @@ import {delay} from 'app/common/delay'; import {OpenDocMode, UserOverride} from 'app/common/DocListAPI'; import {FilteredDocUsageSummary} from 'app/common/DocUsage'; import {Product} from 'app/common/Features'; -import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls'; +import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls'; import {getReconnectTimeout} from 'app/common/gutil'; import {canEdit, isOwner} from 'app/common/roles'; import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI'; @@ -39,6 +39,8 @@ export interface DocInfo extends Document { userOverride: UserOverride|null; isBareFork: boolean; // a document created without logging in, which is treated as a // fork without an original. + isTutorialTrunk: boolean; + isTutorialFork: boolean; idParts: UrlIdParts; openMode: OpenDocMode; } @@ -70,6 +72,8 @@ export interface DocPageModel { isRecoveryMode: Observable; userOverride: Observable; isBareFork: Observable; + isTutorialTrunk: Observable; + isTutorialFork: Observable; importSources: ImportSource[]; @@ -120,6 +124,10 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { (use, doc) => doc ? doc.isRecoveryMode : false); public readonly userOverride = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.userOverride : null); public readonly isBareFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isBareFork : false); + public readonly isTutorialTrunk = Computed.create(this, this.currentDoc, + (use, doc) => doc ? doc.isTutorialTrunk : false); + public readonly isTutorialFork = Computed.create(this, this.currentDoc, + (use, doc) => doc ? doc.isTutorialFork : false); public readonly importSources: ImportSource[] = []; @@ -226,12 +234,22 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { } // Replace the URL without reloading the doc. - public updateUrlNoReload(urlId: string, urlOpenMode: OpenDocMode, options: {replace: boolean}) { + public updateUrlNoReload( + urlId: string, + urlOpenMode: OpenDocMode, + options: {removeSlug?: boolean, replaceUrl?: boolean} = {removeSlug: false, replaceUrl: true} + ) { + const {removeSlug, replaceUrl} = options; const state = urlState().state.get(); - const nextState = {...state, doc: urlId, mode: urlOpenMode === 'default' ? undefined : urlOpenMode}; + const nextState = { + ...state, + doc: urlId, + ...(removeSlug ? {slug: undefined} : undefined), + mode: urlOpenMode === 'default' ? undefined : urlOpenMode, + }; // We preemptively update _openerDocKey so that the URL update doesn't trigger a reload. this._openerDocKey = this._getDocKey(nextState); - return urlState().pushUrl(nextState, {avoidReload: true, ...options}); + return urlState().pushUrl(nextState, {avoidReload: true, replace: replaceUrl}); } public offerRecovery(err: Error) { @@ -283,15 +301,41 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { const gristDocModulePromise = loadGristDoc(); const docResponse = await retryOnNetworkError(flow, getDoc.bind(null, this._api, urlId)); - const doc = buildDocInfo(docResponse, urlOpenMode); flow.checkIfCancelled(); - if (doc.urlId && doc.urlId !== urlId) { - // Replace the URL to reflect the canonical urlId. - await this.updateUrlNoReload(doc.urlId, doc.openMode, {replace: true}); - } + let doc = buildDocInfo(docResponse, urlOpenMode); + if (doc.isTutorialTrunk) { + // We're loading a tutorial, so we need to prepare a URL to a fork of the + // tutorial. The URL will either be to an existing fork, or a new fork if this + // is the first time the user is opening the tutorial. + const fork = doc.forks?.[0]; + let forkUrlId: string | undefined; + if (fork) { + // If a fork of this tutorial already exists, prepare to navigate to it. + forkUrlId = buildUrlId({ + trunkId: doc.urlId || doc.id, + forkId: fork.id, + forkUserId: this.appModel.currentValidUser!.id + }); + } else { + // Otherwise, create a new fork and prepare to navigate to it. + const forkResult = await this._api.getDocAPI(doc.id).fork(); + flow.checkIfCancelled(); + forkUrlId = forkResult.urlId; + } + // Remove the slug from the fork URL - they don't work with slugs. + await this.updateUrlNoReload(forkUrlId, 'default', {removeSlug: true}); + await this.updateCurrentDoc(forkUrlId, 'default'); + flow.checkIfCancelled(); + doc = this.currentDoc.get()!; + } else { + if (doc.urlId && doc.urlId !== urlId) { + // Replace the URL to reflect the canonical urlId. + await this.updateUrlNoReload(doc.urlId, doc.openMode); + } - this.currentDoc.set(doc); + this.currentDoc.set(doc); + } // Maintain a connection to doc-worker while opening a document. After it's opened, the DocComm // object created by GristDoc will maintain the connection. @@ -316,7 +360,8 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { // The current document has been forked, and should now be referred to using a new docId. const currentDoc = this.currentDoc.get(); if (currentDoc) { - await this.updateUrlNoReload(newUrlId, 'default', {replace: false}); + // Remove the slug from the fork URL - they don't work with slugs. + await this.updateUrlNoReload(newUrlId, 'default', {removeSlug: true, replaceUrl: false}); await this.updateCurrentDoc(newUrlId, 'default'); } }); @@ -396,6 +441,8 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo { const isPreFork = (openMode === 'fork'); const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE; + const isTutorialTrunk = !isFork && doc.type === 'tutorial' && mode !== 'default'; + const isTutorialFork = isFork && doc.type === 'tutorial'; const isEditable = canEdit(doc.access) || isPreFork; return { ...doc, @@ -404,6 +451,8 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo { userOverride: null, // ditto. isPreFork, isBareFork, + isTutorialTrunk, + isTutorialFork, isReadonly: !isEditable, idParts, openMode, diff --git a/app/client/models/entities/PageRec.ts b/app/client/models/entities/PageRec.ts index 6ca1c855..e0817ae7 100644 --- a/app/client/models/entities/PageRec.ts +++ b/app/client/models/entities/PageRec.ts @@ -14,6 +14,7 @@ export function createPageRec(this: PageRec, docModel: DocModel): void { // Page is hidden when any of this is true: // - It has an empty name (or no name at all) // - It is GristDocTour (unless user wants to see it) + // - It is GristDocTutorial (and the document is a tutorial fork) // - It is a page generated for a hidden table TODO: Follow up - don't create // pages for hidden tables. // This is used currently only the left panel, to hide pages from the user. @@ -26,7 +27,11 @@ export function createPageRec(this: PageRec, docModel: DocModel): void { const primaryTable = tables.find(t => t.primaryViewId() === viewId); return !!primaryTable && primaryTable.tableId()?.startsWith("GristHidden_"); }; - return (name === 'GristDocTour' && !docModel.showDocTourTable) || isTableHidden(); + return ( + (name === 'GristDocTour' && !docModel.showDocTourTable) || + (name === 'GristDocTutorial' && !docModel.showDocTutorialTable) || + isTableHidden() + ); }); this.isHidden = ko.pureComputed(() => { return this.isCensored() || this.isSpecial(); diff --git a/app/client/ui/DocTutorial.css b/app/client/ui/DocTutorial.css new file mode 100644 index 00000000..1dc4e49b --- /dev/null +++ b/app/client/ui/DocTutorial.css @@ -0,0 +1,96 @@ +.doc-tutorial-popup h1, +.doc-tutorial-popup h2, +.doc-tutorial-popup h3, +.doc-tutorial-popup p, +.doc-tutorial-popup li { + color: var(--grist-theme-text, #262633); +} + +.doc-tutorial-popup h1 { + margin: 0px 0px 24px 0px; + font-weight: 500; + font-size: 28px; + line-height: 40px; +} + +.doc-tutorial-popup h2 { + margin: 20px 0px 10px 0px; + font-weight: 400; + font-size: 24px; + line-height: 32px; +} + +.doc-tutorial-popup h3 { + margin: 20px 0px 10px 0px; + font-weight: 400; + font-size: 20px; + line-height: 24px; +} + +.doc-tutorial-popup p { + margin: 0px 0px 16px 0px; + font-weight: 400; + font-size: 14px; + line-height: 22px; +} + +.doc-tutorial-popup a, +.doc-tutorial-popup a:hover { + color: var(--grist-theme-link, #16B378); +} + +.doc-tutorial-popup li { + font-weight: 400; + font-size: 14px; + line-height: 22px; +} + +.doc-tutorial-popup ol, +.doc-tutorial-popup ul { + margin: 0px 0px 10px 0px; +} + +.doc-tutorial-popup code { + padding: 2px 5px; + background: #FFFFFF; + border: 1px solid #E1E4E5; + color: #333333; + white-space: pre-wrap; + word-wrap: break-word; +} + +.doc-tutorial-popup iframe { + border: none; +} + +.doc-tutorial-popup-thumbnail { + position: relative; + margin: 20px 0px 30px 0px; + cursor: pointer; +} + +.doc-tutorial-popup-thumbnail img { + width: 100%; + border: 1px solid var(--grist-theme-tutorials-popup-border, #D9D9D9); + border-radius: 4px; + padding: 4px; + background-color: transparent; +} + +.doc-tutorial-popup-thumbnail-icon-wrapper { + pointer-events: none; + position: absolute; + bottom: 8px; + right: 8px; + padding: 4px; + background-color: #D9D9D9; + border-radius: 4px; +} + +.doc-tutorial-popup-thumbnail-icon { + mask-image: var(--icon-Maximize); + -webkit-mask-image: var(--icon-Maximize); + background-color: var(--grist-theme-accent-icon, var(--grist-color-light-green)); + width: 16px; + height: 16px; +} diff --git a/app/client/ui/DocTutorial.ts b/app/client/ui/DocTutorial.ts new file mode 100644 index 00000000..40fc554f --- /dev/null +++ b/app/client/ui/DocTutorial.ts @@ -0,0 +1,639 @@ +import {GristDoc} from 'app/client/components/GristDoc'; +import {urlState} from 'app/client/models/gristUrlState'; +import {renderer} from 'app/client/ui/DocTutorialRenderer'; +import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; +import {hoverTooltip} from 'app/client/ui/tooltips'; +import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; +import {isNarrowScreen, isNarrowScreenObs, mediaXSmall, theme} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {loadingSpinner} from 'app/client/ui2018/loaders'; +import {confirmModal, modal} from 'app/client/ui2018/modals'; +import {parseUrlId} from 'app/common/gristUrls'; +import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs'; +import {marked} from 'marked'; +import debounce = require('lodash/debounce'); +import range = require('lodash/range'); +import sortBy = require('lodash/sortBy'); + +const POPUP_PADDING_PX = 16; + +interface DocTutorialSlide { + slideContent: string; + boxContent?: string; + slideTitle?: string; + imageUrls: string[]; +} + +const testId = makeTestId('test-doc-tutorial-'); + +export class DocTutorial extends Disposable { + private _appModel = this._gristDoc.docPageModel.appModel; + private _currentDoc = this._gristDoc.docPageModel.currentDoc.get(); + private _docComm = this._gristDoc.docComm; + private _docData = this._gristDoc.docData; + private _docId = this._gristDoc.docId(); + private _popupElement: HTMLElement | null = null; + private _slides: Observable = Observable.create(this, null); + private _currentSlideIndex = Observable.create(this, + this._currentDoc?.forks?.[0]?.options?.tutorial?.lastSlideIndex ?? 0); + private _isMinimized = Observable.create(this, false); + + private _clientX: number; + private _clientY: number; + + private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, { + // Save new position immediately if at least 1 second has passed since the last change. + leading: true, + // Otherwise, wait for the new position to settle for 1 second before saving it. + trailing: true + }); + + constructor(private _gristDoc: GristDoc) { + super(); + + this._handleMouseDown = this._handleMouseDown.bind(this); + this._handleMouseMove = this._handleMouseMove.bind(this); + this._handleMouseUp = this._handleMouseUp.bind(this); + this._handleTouchStart = this._handleTouchStart.bind(this); + this._handleTouchMove = this._handleTouchMove.bind(this); + this._handleTouchEnd = this._handleTouchEnd.bind(this); + this._handleWindowResize = this._handleWindowResize.bind(this); + + this.autoDispose(isNarrowScreenObs().addListener(() => this._repositionPopup())); + + this.onDispose(() => { + this._closePopup(); + }); + } + + public async start() { + this._showPopup(); + await this._loadSlides(); + } + + private async _loadSlides() { + const tableId = 'GristDocTutorial'; + if (!this._docData.getTable(tableId)) { + throw new Error('DocTutorial failed to find table GristDocTutorial'); + } + + await this._docComm.waitForInitialization(); + if (this.isDisposed()) { return; } + + await this._docData.fetchTable(tableId); + if (this.isDisposed()) { return; } + + const tableData = this._docData.getTable(tableId)!; + const slides = (await Promise.all( + sortBy(tableData.getRowIds(), tableData.getRowPropFunc('manualSort') as any) + .map(async rowId => { + let slideTitle: string | undefined; + const imageUrls: string[] = []; + + const getValue = (colId: string): string | undefined => { + const value = tableData.getValue(rowId, colId); + return value ? String(value) : undefined; + }; + + const walkTokens = (token: marked.Token) => { + if (token.type === 'image') { + imageUrls.push(token.href); + } + + if (!slideTitle && token.type === 'heading' && token.depth === 1) { + slideTitle = token.text; + } + }; + + let slideContent = getValue('slide_content'); + if (!slideContent) { return null; } + slideContent = sanitizeHTML(await marked.parse(slideContent, { + async: true, renderer, walkTokens + })); + + let boxContent = getValue('box_content'); + if (boxContent) { + boxContent = sanitizeHTML(await marked.parse(boxContent, { + async: true, renderer, walkTokens + })); + } + return { + slideContent, + boxContent, + slideTitle, + imageUrls, + }; + }) + )).filter(slide => slide !== null) as DocTutorialSlide[]; + if (this.isDisposed()) { return; } + + if (slides.length === 0) { + throw new Error('DocTutorial failed to find slides in table GristDocTutorial'); + } + + this._slides.set(slides); + } + + private _showPopup() { + this._popupElement = this._buildPopup(); + document.body.appendChild(this._popupElement); + + const topPaddingPx = getTopPopupPaddingPx(); + const initialLeft = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_PADDING_PX; + const initialTop = document.body.offsetHeight - this._popupElement.offsetHeight - topPaddingPx; + this._popupElement.style.left = `${initialLeft}px`; + this._popupElement.style.top = `${initialTop}px`; + } + + private _closePopup() { + if (!this._popupElement) { return; } + + document.body.removeChild(this._popupElement); + dom.domDispose(this._popupElement); + this._popupElement = null; + } + + private _handleMouseDown(ev: MouseEvent) { + this._clientX = ev.clientX; + this._clientY = ev.clientY; + document.addEventListener('mousemove', this._handleMouseMove); + document.addEventListener('mouseup', this._handleMouseUp); + } + + private _handleTouchStart(ev: TouchEvent) { + this._clientX = ev.touches[0].clientX; + this._clientY = ev.touches[0].clientY; + document.addEventListener('touchmove', this._handleTouchMove); + document.addEventListener('touchend', this._handleTouchEnd); + } + + private _handleMouseMove({clientX, clientY}: MouseEvent) { + this._handleMove(clientX, clientY); + } + + private _handleTouchMove({touches}: TouchEvent) { + this._handleMove(touches[0].clientX, touches[0].clientY); + } + + private _handleMove(clientX: number, clientY: number) { + const deltaX = clientX - this._clientX; + const deltaY = clientY - this._clientY; + let newLeft = this._popupElement!.offsetLeft + deltaX; + let newTop = this._popupElement!.offsetTop + deltaY; + + const topPaddingPx = getTopPopupPaddingPx(); + if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; } + if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; } + if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) { + newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX; + } + if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) { + newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx; + } + + this._popupElement!.style.left = `${newLeft}px`; + this._popupElement!.style.top = `${newTop}px`; + this._clientX = clientX; + this._clientY = clientY; + } + + private _handleMouseUp() { + document.removeEventListener('mousemove', this._handleMouseMove); + document.removeEventListener('mouseup', this._handleMouseUp); + document.body.removeEventListener('mouseleave', this._handleMouseUp); + } + + private _handleTouchEnd() { + document.removeEventListener('touchmove', this._handleTouchMove); + document.removeEventListener('touchend', this._handleTouchEnd); + document.body.removeEventListener('touchcancel', this._handleTouchEnd); + } + + private _handleWindowResize() { + this._repositionPopup(); + } + + private _repositionPopup() { + let newLeft = this._popupElement!.offsetLeft; + let newTop = this._popupElement!.offsetTop; + + const topPaddingPx = getTopPopupPaddingPx(); + if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; } + if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; } + if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) { + newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX; + } + if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) { + newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx; + } + + this._popupElement!.style.left = `${newLeft}px`; + this._popupElement!.style.top = `${newTop}px`; + } + + private async _saveCurrentSlidePosition() { + const currentOptions = this._currentDoc?.options ?? {}; + await this._appModel.api.updateDoc(this._docId, { + options: { + ...currentOptions, + tutorial: { + lastSlideIndex: this._currentSlideIndex.get(), + } + } + }); + } + + private async _changeSlide(slideIndex: number) { + this._currentSlideIndex.set(slideIndex); + await this._saveCurrentSlidePositionDebounced(); + } + + private async _previousSlide() { + await this._changeSlide(this._currentSlideIndex.get() - 1); + } + + private async _nextSlide() { + await this._changeSlide(this._currentSlideIndex.get() + 1); + } + + private async _finishTutorial() { + this._saveCurrentSlidePositionDebounced.cancel(); + await this._saveCurrentSlidePosition(); + await urlState().pushUrl({}); + } + + private async _restartTutorial() { + const doRestart = async () => { + const urlId = this._currentDoc!.id; + const {trunkId} = parseUrlId(urlId); + const docApi = this._appModel.api.getDocAPI(urlId); + await docApi.replace({sourceDocId: trunkId, resetTutorialMetadata: true}); + }; + + confirmModal( + 'Do you want to restart the tutorial? All progress will be lost.', + 'Restart', + doRestart + ); + } + + private _restartGIFs() { + return (element: HTMLElement) => { + setTimeout(() => { + const imgs = element.querySelectorAll('img'); + for (const img of imgs) { + // Re-assigning src to itself is a neat way to restart a GIF. + // eslint-disable-next-line no-self-assign + img.src = img.src; + } + }, 0); + }; + } + + private _buildPopup() { + return cssPopup( + {tabIndex: '-1'}, + cssPopupHeader( + dom.domComputed(this._isMinimized, isMinimized => { + return [ + cssPopupHeaderSpacer(), + cssPopupTitle( + cssPopupTitleText(dom.text(this._gristDoc.docPageModel.currentDocTitle)), + testId('popup-title'), + ), + cssPopupHeaderButton( + isMinimized ? icon('Maximize'): icon('Minimize'), + hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}), + dom.on('click', () => { + this._isMinimized.set(!this._isMinimized.get()); + this._repositionPopup(); + }), + testId('popup-minimize-maximize'), + ), + ]; + }), + dom.on('mousedown', this._handleMouseDown), + dom.on('touchstart', this._handleTouchStart), + testId('popup-header'), + ), + dom.maybe(use => !use(this._isMinimized), () => [ + dom.domComputed(use => { + const slides = use(this._slides); + const slideIndex = use(this._currentSlideIndex); + const slide = slides?.[slideIndex]; + return cssPopupBody( + !slide ? cssSpinner(loadingSpinner()) : [ + dom('div', elem => { + elem.innerHTML = slide.slideContent; + }), + !slide.boxContent ? null : cssTryItOutBox( + dom('div', elem => { elem.innerHTML = slide.boxContent!; }), + ), + dom.on('click', (ev) => { + if((ev.target as HTMLElement).tagName !== 'IMG') { + return; + } + + this._openLightbox((ev.target as HTMLImageElement).src); + }), + this._restartGIFs(), + ], + testId('popup-body'), + ); + }), + cssPopupFooter( + dom.domComputed(use => { + const slides = use(this._slides); + if (!slides) { return null; } + + const slideIndex = use(this._currentSlideIndex); + const numSlides = slides.length; + const isFirstSlide = slideIndex === 0; + const isLastSlide = slideIndex === numSlides - 1; + return [ + cssFooterButtonsLeft( + cssPopupFooterButton(icon('Undo'), + hoverTooltip('Restart Tutorial', {key: 'docTutorialTooltip'}), + dom.on('click', () => this._restartTutorial()), + testId('popup-restart'), + ), + ), + cssProgressBar( + range(slides.length).map((i) => cssProgressBarDot( + {title: slides[i].slideTitle}, + cssProgressBarDot.cls('-current', i === slideIndex), + i === slideIndex ? null : dom.on('click', () => this._changeSlide(i)), + testId(`popup-slide-${i + 1}`), + )), + ), + cssFooterButtonsRight( + basicButton('Previous', + dom.on('click', async () => { + await this._previousSlide(); + }), + {style: `visibility: ${isFirstSlide ? 'hidden' : 'visible'}`}, + testId('popup-previous'), + ), + primaryButton(isLastSlide ? 'Finish': 'Next', + isLastSlide + ? dom.on('click', async () => await this._finishTutorial()) + : dom.on('click', async () => await this._nextSlide()), + testId('popup-next'), + ), + ), + ]; + }), + testId('popup-footer'), + ), + ]), + // Pre-fetch images from all slides and store them in a hidden div. + dom.maybe(this._slides, slides => + dom('div', + {style: 'display: none;'}, + dom.forEach(slides, slide => { + if (slide.imageUrls.length === 0) { return null; } + + return dom('div', slide.imageUrls.map(src => dom('img', {src}))); + }), + ), + ), + () => { window.addEventListener('resize', this._handleWindowResize); }, + dom.onDispose(() => { + document.removeEventListener('mousemove', this._handleMouseMove); + document.removeEventListener('mouseup', this._handleMouseUp); + document.removeEventListener('touchmove', this._handleTouchMove); + document.removeEventListener('touchend', this._handleTouchEnd); + window.removeEventListener('resize', this._handleWindowResize); + }), + cssPopup.cls('-minimized', this._isMinimized), + cssPopup.cls('-mobile', isNarrowScreenObs()), + dom.cls('doc-tutorial-popup'), + testId('popup'), + ); + } + + private _openLightbox(src: string) { + modal((ctl) => { + this.onDispose(ctl.close); + return [ + cssFullScreenModal.cls(''), + cssModalCloseButton('CrossBig', + dom.on('click', () => ctl.close()), + testId('lightbox-close'), + ), + cssModalContent(cssModalImage({src}, testId('lightbox-image'))), + dom.on('click', (ev, elem) => void (ev.target === elem ? ctl.close() : null)), + testId('lightbox'), + ]; + }); + } +} + +function getTopPopupPaddingPx(): number { + // On mobile, we need additional padding to avoid blocking the top and bottom bars. + return POPUP_PADDING_PX + (isNarrowScreen() ? 50 : 0); +} + +const POPUP_HEIGHT = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`; +const POPUP_HEIGHT_MOBILE = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px) - (2 * 50px)))`; +const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`; + +const cssPopup = styled('div', ` + position: absolute; + display: flex; + flex-direction: column; + border: 2px solid ${theme.accentBorder}; + border-radius: 5px; + z-index: 999; + height: ${POPUP_HEIGHT}; + width: ${POPUP_WIDTH}; + background-color: ${theme.popupBg}; + box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow}; + outline: unset; + + &-mobile { + height: ${POPUP_HEIGHT_MOBILE}; + } + + &-minimized { + max-width: 225px; + height: unset; + } + + &-minimized:not(&-mobile) { + max-height: ${POPUP_HEIGHT}; + } + + &-minimized&-mobile { + max-height: ${POPUP_HEIGHT_MOBILE}; + } +`); + +const cssPopupHeader = styled('div', ` + display: flex; + color: ${theme.tutorialsPopupHeaderFg}; + --icon-color: ${theme.tutorialsPopupHeaderFg}; + background-color: ${theme.accentBorder}; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + cursor: grab; + padding-left: 4px; + padding-right: 4px; + height: 30px; + user-select: none; + column-gap: 8px; + + &:active { + cursor: grabbing; + } +`); + +const cssPopupTitle = styled('div', ` + display: flex; + justify-content: center; + align-items: center; + font-weight: 600; + overflow: hidden; +`); + +const cssPopupTitleText = styled('div', ` + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`); + +const cssPopupBody = styled('div', ` + flex-grow: 1; + padding: 24px; + overflow: auto; +`); + +const cssPopupFooter = styled('div', ` + display: flex; + column-gap: 24px; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding: 24px 16px 24px 16px; + border-top: 1px solid ${theme.tutorialsPopupBorder}; +`); + +const cssTryItOutBox = styled('div', ` + margin-top: 16px; + padding: 24px; + border-radius: 4px; + background-color: ${theme.tutorialsPopupBoxBg}; +`); + +const cssPopupHeaderButton = styled('div', ` + padding: 4px; + border-radius: 4px; + cursor: pointer; + + &:hover { + background-color: ${theme.hover}; + } +`); + +const cssPopupHeaderSpacer = styled('div', ` + width: 24px; + height: 24px; +`); + +const cssPopupFooterButton = styled('div', ` + --icon-color: ${theme.controlSecondaryFg}; + padding: 4px; + border-radius: 4px; + cursor: pointer; + + &:hover { + background-color: ${theme.hover}; + } +`); + +const cssProgressBar = styled('div', ` + display: flex; + gap: 8px; + flex-grow: 1; + flex-wrap: wrap; +`); + +const cssProgressBarDot = styled('div', ` + width: 10px; + height: 10px; + border-radius: 5px; + align-self: center; + cursor: pointer; + background-color: ${theme.progressBarBg}; + + &-current { + cursor: default; + background-color: ${theme.progressBarFg}; + } +`); + +const cssFooterButtonsLeft = styled('div', ` + flex-shrink: 0; +`); + +const cssFooterButtonsRight = styled('div', ` + display: flex; + justify-content: flex-end; + column-gap: 8px; + flex-shrink: 0; + min-width: 140px; + + @media ${mediaXSmall} { + & { + flex-direction: column; + row-gap: 8px; + column-gap: 0px; + min-width: 0px; + } + } +`); + +const cssFullScreenModal = styled('div', ` + display: flex; + flex-direction: column; + row-gap: 8px; + background-color: initial; + width: 100%; + height: 100%; + border: none; + border-radius: 0px; + box-shadow: none; + padding: 0px; +`); + +const cssModalCloseButton = styled(icon, ` + align-self: flex-end; + flex-shrink: 0; + height: 24px; + width: 24px; + cursor: pointer; + --icon-color: ${theme.modalBackdropCloseButtonFg}; + &:hover { + --icon-color: ${theme.modalBackdropCloseButtonHoverFg}; + } +`); + +const cssModalContent = styled('div', ` + align-self: center; + min-height: 0; + margin-top: auto; + margin-bottom: auto; +`); + +const cssModalImage = styled('img', ` + height: 100%; + max-width: min(100%, 1200px); +`); + +const cssSpinner = styled('div', ` + display: flex; + justify-content: center; + align-items: center; + height: 100%; +`); diff --git a/app/client/ui/DocTutorialRenderer.ts b/app/client/ui/DocTutorialRenderer.ts new file mode 100644 index 00000000..987139aa --- /dev/null +++ b/app/client/ui/DocTutorialRenderer.ts @@ -0,0 +1,16 @@ +import {marked} from 'marked'; + +export const renderer = new marked.Renderer(); + +renderer.image = (href: string, text: string) => { + return `
+ +
+
+
+
`; +}; + +renderer.link = (href: string, _title: string, text: string) => { + return `${text}`; +}; diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts index 1c65bd9f..bf253560 100644 --- a/app/client/ui/ShareMenu.ts +++ b/app/client/ui/ShareMenu.ts @@ -44,6 +44,8 @@ export function buildShareMenuButton(pageModel: DocPageModel): DomContents { menuOriginal(doc, appModel, true), menuExports(doc, pageModel), ], {buttonAction: backToCurrent}); + } else if (doc.isTutorialFork) { + return null; } else if (doc.isPreFork || doc.isBareFork) { // A new unsaved document, or a fiddle, or a public example. const saveActionTitle = doc.isBareFork ? t("Save Document") : t("Save Copy"); diff --git a/app/client/ui/Tools.ts b/app/client/ui/Tools.ts index c99cece9..1ca56d8f 100644 --- a/app/client/ui/Tools.ts +++ b/app/client/ui/Tools.ts @@ -120,7 +120,7 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse ); }), // Show the 'Tour of this Document' button if a GristDocTour table exists. - dom.maybe(gristDoc.hasDocTour, () => + dom.maybe(use => use(gristDoc.docModel.hasDocTour) && !use(gristDoc.docModel.isTutorial), () => cssSplitPageEntry( cssPageEntryMain( cssPageLink(cssPageIcon('Page'), diff --git a/app/client/ui/TopBar.ts b/app/client/ui/TopBar.ts index f3019714..6cf3db63 100644 --- a/app/client/ui/TopBar.ts +++ b/app/client/ui/TopBar.ts @@ -84,6 +84,7 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode isFork: pageModel.isFork, isBareFork: pageModel.isBareFork, isRecoveryMode: pageModel.isRecoveryMode, + isTutorialFork: pageModel.isTutorialFork, isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork)), isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)), isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)), diff --git a/app/client/ui/sanitizeHTML.ts b/app/client/ui/sanitizeHTML.ts new file mode 100644 index 00000000..e793ca7f --- /dev/null +++ b/app/client/ui/sanitizeHTML.ts @@ -0,0 +1,26 @@ +import DOMPurify from 'dompurify'; + +const config = { + ADD_TAGS: ['iframe'], + ADD_ATTR: ['allowFullscreen'], +}; + +DOMPurify.addHook('uponSanitizeAttribute', (node) => { + if (!('target' in node)) { return; } + + node.setAttribute('target', '_blank'); +}); +DOMPurify.addHook('uponSanitizeElement', (node, data) => { + if (data.tagName !== 'iframe') { return; } + + const src = node.getAttribute('src'); + if (src?.startsWith('https://www.youtube.com/embed/')) { + return; + } + + return node.parentNode?.removeChild(node); +}); + +export function sanitizeHTML(source: string | Node): string { + return DOMPurify.sanitize(source, config); +} diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index 626742ce..f51bfb71 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -79,8 +79,10 @@ export type IconName = "ChartArea" | "Lock" | "Log" | "Mail" | + "Maximize" | "Memo" | "Message" | + "Minimize" | "Minus" | "MobileChat" | "MobileChat2" | @@ -214,8 +216,10 @@ export const IconList: IconName[] = ["ChartArea", "Lock", "Log", "Mail", + "Maximize", "Memo", "Message", + "Minimize", "Minus", "MobileChat", "MobileChat2", diff --git a/app/client/ui2018/breadcrumbs.ts b/app/client/ui2018/breadcrumbs.ts index 16723452..ed82f6ad 100644 --- a/app/client/ui2018/breadcrumbs.ts +++ b/app/client/ui2018/breadcrumbs.ts @@ -94,6 +94,7 @@ export function docBreadcrumbs( isDocNameReadOnly?: BindableValue, isPageNameReadOnly?: BindableValue, isFork: Observable, + isTutorialFork: Observable, isBareFork: Observable, isFiddle: Observable, isRecoveryMode: Observable, @@ -140,7 +141,7 @@ export function docBreadcrumbs( if (options.isSnapshot && use(options.isSnapshot)) { return cssTag(t("snapshot"), testId('snapshot-tag')); } - if (use(options.isFork)) { + if (use(options.isFork) && !use(options.isTutorialFork)) { return cssTag(t("unsaved"), testId('unsaved-tag')); } if (use(options.isRecoveryMode)) { diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 983c2e68..03f15f07 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -694,6 +694,13 @@ export const theme = { colors.mediumGreyOpaque), datePickerRangeBgHover: new CustomProp('theme-date-picker-range-bg-hover', undefined, colors.darkGrey), + + /* Tutorials */ + tutorialsPopupBorder: new CustomProp('theme-tutorials-popup-border', undefined, + colors.darkGrey), + tutorialsPopupHeaderFg: new CustomProp('theme-tutorials-popup-header-fg', undefined, + colors.lightGreen), + tutorialsPopupBoxBg: new CustomProp('theme-tutorials-popup-box-bg', undefined, '#F5F5F5'), }; const cssColors = values(colors).map(v => v.decl()).join('\n'); diff --git a/app/common/ThemePrefs-ti.ts b/app/common/ThemePrefs-ti.ts index 37c81d5a..14f3df00 100644 --- a/app/common/ThemePrefs-ti.ts +++ b/app/common/ThemePrefs-ti.ts @@ -345,6 +345,9 @@ export const ThemeColors = t.iface([], { "date-picker-range-start-end-bg-hover": "string", "date-picker-range-bg": "string", "date-picker-range-bg-hover": "string", + "tutorials-popup-border": "string", + "tutorials-popup-header-fg": "string", + "tutorials-popup-box-bg": "string", }); const exportedTypeSuite: t.ITypeSuite = { diff --git a/app/common/ThemePrefs.ts b/app/common/ThemePrefs.ts index 80495567..ff6825fd 100644 --- a/app/common/ThemePrefs.ts +++ b/app/common/ThemePrefs.ts @@ -451,6 +451,11 @@ export interface ThemeColors { 'date-picker-range-start-end-bg-hover': string; 'date-picker-range-bg': string; 'date-picker-range-bg-hover': string; + + /* Tutorials */ + 'tutorials-popup-border': string; + 'tutorials-popup-header-fg': string; + 'tutorials-popup-box-bg': string; } export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT; diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 53a28c83..7affb662 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -119,6 +119,11 @@ export interface DocumentOptions { openMode?: OpenDocMode|null; externalId?: string|null; // A slot for storing an externally maintained id. // Not used in grist-core, but handy for Electron app. + tutorial?: TutorialMetadata|null; +} + +export interface TutorialMetadata { + lastSlideIndex?: number; } export interface DocumentProperties extends CommonProperties { @@ -129,7 +134,7 @@ export interface DocumentProperties extends CommonProperties { options: DocumentOptions|null; } -export const documentPropertyKeys = [...commonPropertyKeys, 'isPinned', 'urlId', 'options']; +export const documentPropertyKeys = [...commonPropertyKeys, 'isPinned', 'urlId', 'options', 'type']; export interface Document extends DocumentProperties { id: string; @@ -143,6 +148,7 @@ export interface Fork { id: string; trunkId: string; updatedAt: string; // ISO date string + options: DocumentOptions|null; } // Non-core options for a user. @@ -241,8 +247,21 @@ export interface OrgError { * (e.g. a fork) or from a snapshot. */ export interface DocReplacementOptions { - sourceDocId?: string; // docId to copy from - snapshotId?: string; // s3 VersionId + /** + * The docId to copy from. + */ + sourceDocId?: string; + /** + * The s3 version ID. + */ + snapshotId?: string; + /** + * True if tutorial metadata should be reset. + * + * Metadata that's reset includes the doc (i.e. tutorial) name, and the + * properties under options.tutorial (e.g. lastSlideIndex). + */ + resetTutorialMetadata?: boolean; } /** diff --git a/app/common/themes/GristDark.ts b/app/common/themes/GristDark.ts index fc480199..8ef30e75 100644 --- a/app/common/themes/GristDark.ts +++ b/app/common/themes/GristDark.ts @@ -430,4 +430,9 @@ export const GristDark: ThemeColors = { 'date-picker-range-start-end-bg-hover': '#8F8F8F', 'date-picker-range-bg': '#57575F', 'date-picker-range-bg-hover': '#7F7F7F', + + /* Tutorials */ + 'tutorials-popup-border': '#69697D', + 'tutorials-popup-header-fg': '#FFFFFF', + 'tutorials-popup-box-bg': '#57575F', }; diff --git a/app/common/themes/GristLight.ts b/app/common/themes/GristLight.ts index c3918f70..be19657b 100644 --- a/app/common/themes/GristLight.ts +++ b/app/common/themes/GristLight.ts @@ -430,4 +430,9 @@ export const GristLight: ThemeColors = { 'date-picker-range-start-end-bg-hover': '#CFCFCF', 'date-picker-range-bg': '#EEEEEE', 'date-picker-range-bg-hover': '#D9D9D9', + + /* Tutorials */ + 'tutorials-popup-border': '#D9D9D9', + 'tutorials-popup-header-fg': '#FFFFFF', + 'tutorials-popup-box-bg': '#F5F5F5', }; diff --git a/app/gen-server/entity/Document.ts b/app/gen-server/entity/Document.ts index abcf983e..8b7ece9d 100644 --- a/app/gen-server/entity/Document.ts +++ b/app/gen-server/entity/Document.ts @@ -76,9 +76,6 @@ export class Document extends Resource { @Column({name: 'trunk_id', type: 'text', nullable: true}) public trunkId: string|null; - // Property set for forks, containing the URL ID of the trunk. - public trunkUrlId?: string|null; - @ManyToOne(_type => Document, document => document.forks) @JoinColumn({name: 'trunk_id'}) public trunk: Document|null; @@ -123,6 +120,19 @@ export class Document extends Resource { if (props.options.externalId !== undefined) { this.options.externalId = props.options.externalId; } + if (props.options.tutorial !== undefined) { + // Tutorial metadata is merged over the existing state - unless + // metadata is set to "null", in which case the state is wiped + // completely. + if (props.options.tutorial === null) { + this.options.tutorial = null; + } else { + this.options.tutorial = this.options.tutorial || {}; + if (props.options.tutorial.lastSlideIndex !== undefined) { + this.options.tutorial.lastSlideIndex = props.options.tutorial.lastSlideIndex; + } + } + } // Normalize so that null equates with absence. for (const key of Object.keys(this.options) as Array) { if (this.options[key] === null) { diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 134b2ac3..036b6ca1 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -1008,9 +1008,13 @@ export class HomeDBManager extends EventEmitter { * Returns a QueryResult for the workspace with the given workspace id. The workspace * includes nested Docs. */ - public async getWorkspace(scope: Scope, wsId: number): Promise> { + public async getWorkspace( + scope: Scope, + wsId: number, + transaction?: EntityManager + ): Promise> { const {userId} = scope; - let queryBuilder = this._workspaces() + let queryBuilder = this._workspaces(transaction) .where('workspaces.id = :wsId', {wsId}) // Nest the docs within the workspace object .leftJoinAndSelect('workspaces.docs', 'docs', this._onDoc(scope)) @@ -1161,7 +1165,7 @@ export class HomeDBManager extends EventEmitter { // TODO: The return type of this function includes the workspace and org with the owner // properties set, as documented in app/common/UserAPI. The return type of this function // should reflect that. - public async getDocImpl(key: DocAuthKey): Promise { + public async getDocImpl(key: DocAuthKey, transaction?: EntityManager): Promise { const {userId} = key; // Doc permissions of forks are based on the "trunk" document, so make sure // we look up permissions of trunk if we are on a fork (we'll fix the permissions @@ -1196,8 +1200,11 @@ export class HomeDBManager extends EventEmitter { // it is very simple at the single-document level. So we direct the db to include // everything with showAll flag, and let the getDoc() wrapper deal with the remaining // work. - let qb = this._doc({...key, showAll: true}) + let qb = this._doc({...key, showAll: true}, {manager: transaction}) .leftJoinAndSelect('orgs.owner', 'org_users'); + if (userId !== this.getAnonymousUserId()) { + qb = this._addForks(userId, qb); + } qb = this._addIsSupportWorkspace(userId, qb, 'orgs', 'workspaces'); qb = this._addFeatures(qb); // add features to determine whether we've gone readonly const docs = this.unwrapQueryResult(await this._verifyAclPermissions(qb)); @@ -1214,7 +1221,6 @@ export class HomeDBManager extends EventEmitter { } if (forkId || snapshotId) { doc.trunkId = doc.id; - doc.trunkUrlId = doc.urlId; // Fix up our reply to be correct for the fork, rather than the trunk. // The "id" and "urlId" fields need updating. @@ -1227,17 +1233,20 @@ export class HomeDBManager extends EventEmitter { doc.trunkAccess = doc.access; // Update access for fork. - this._setForkAccess({userId, forkUserId, snapshotId}, doc); + this._setForkAccess(doc, {userId, forkUserId, snapshotId}, doc); + if (!doc.access) { + throw new ApiError('access denied', 403); + } } return doc; } // Calls getDocImpl() and returns the Document from that, caching a fresh DocAuthResult along // the way. Note that we only cache the access level, not Document itself. - public async getDoc(reqOrScope: Request | Scope): Promise { + public async getDoc(reqOrScope: Request | Scope, transaction?: EntityManager): Promise { const scope = "params" in reqOrScope ? getScope(reqOrScope) : reqOrScope; const key = getDocAuthKeyFromScope(scope); - const promise = this.getDocImpl(key); + const promise = this.getDocImpl(key, transaction); await mapSetOrClear(this._docAuthCache, stringifyDocAuthKey(key), makeDocAuthResult(promise)); const doc = await promise; // Filter the result for removed / non-removed documents. @@ -1249,8 +1258,12 @@ export class HomeDBManager extends EventEmitter { return doc; } - public async getRawDocById(docId: string) { - return await this.getDoc({urlId: docId, userId: this.getPreviewerUserId(), showAll: true}); + public async getRawDocById(docId: string, transaction?: EntityManager) { + return await this.getDoc({ + urlId: docId, + userId: this.getPreviewerUserId(), + showAll: true + }, transaction); } // Returns access info for the given doc and user, caching the results for DOC_AUTH_CACHE_TTL @@ -1878,25 +1891,39 @@ export class HomeDBManager extends EventEmitter { // query result with status 200 on success. // NOTE: This does not update the updateAt date indicating the last modified time of the doc. // We may want to make it do so. - public async updateDocument(scope: DocScope, - props: Partial): Promise> { - + public async updateDocument( + scope: DocScope, + props: Partial, + transaction?: EntityManager + ): Promise> { const markPermissions = Permissions.SCHEMA_EDIT; - return await this._connection.transaction(async manager => { - const docQuery = this._doc(scope, { - manager, - markPermissions - }); - - const queryResult = await verifyIsPermitted(docQuery); + return await this._runInTransaction(transaction, async (manager) => { + const {forkId} = parseUrlId(scope.urlId); + let query: SelectQueryBuilder; + if (forkId) { + query = this._fork(scope, { + manager, + }); + } else { + query = this._doc(scope, { + manager, + markPermissions, + }); + } + const queryResult = await verifyIsPermitted(query); if (queryResult.status !== 200) { - // If the query for the doc failed, return the failure result. + // If the query for the doc or fork failed, return the failure result. return queryResult; } // Update the name and save. const doc: Document = queryResult.data; doc.checkProperties(props); doc.updateFromProperties(props); + if (forkId) { + await manager.save(doc); + return {status: 200}; + } + // Forcibly remove the aliases relation from the document object, so that TypeORM // doesn't try to save it. It isn't safe to do that because it was filtered by // a where clause. @@ -1930,28 +1957,44 @@ export class HomeDBManager extends EventEmitter { // status 200 on success. public async deleteDocument(scope: DocScope): Promise> { return await this._connection.transaction(async manager => { - const docQuery = this._doc(scope, { - manager, - markPermissions: Permissions.REMOVE | Permissions.SCHEMA_EDIT, - allowSpecialPermit: true - }) - // Join the docs's ACLs and groups so we can remove them. - // Join the workspace and org to get their ids. - .leftJoinAndSelect('docs.aclRules', 'acl_rules') - .leftJoinAndSelect('acl_rules.group', 'groups'); - const queryResult = await verifyIsPermitted(docQuery); - if (queryResult.status !== 200) { - // If the query for the workspace failed, return the failure result. - return queryResult; + const {forkId} = parseUrlId(scope.urlId); + if (forkId) { + const forkQuery = this._fork(scope, { + manager, + allowSpecialPermit: true, + }); + const queryResult = await verifyIsPermitted(forkQuery); + if (queryResult.status !== 200) { + // If the query for the fork failed, return the failure result. + return queryResult; + } + const fork: Document = queryResult.data; + await manager.remove([fork]); + return {status: 200}; + } else { + const docQuery = this._doc(scope, { + manager, + markPermissions: Permissions.REMOVE | Permissions.SCHEMA_EDIT, + allowSpecialPermit: true + }) + // Join the docs's ACLs and groups so we can remove them. + // Join the workspace and org to get their ids. + .leftJoinAndSelect('docs.aclRules', 'acl_rules') + .leftJoinAndSelect('acl_rules.group', 'groups'); + const queryResult = await verifyIsPermitted(docQuery); + if (queryResult.status !== 200) { + // If the query for the doc failed, return the failure result. + return queryResult; + } + const doc: Document = queryResult.data; + // Delete the doc and doc ACLs/groups. + const docGroups = doc.aclRules.map(docAcl => docAcl.group); + await manager.remove([doc, ...docGroups, ...doc.aclRules]); + // Update guests of the workspace and org after removing this doc. + await this._repairWorkspaceGuests(scope, doc.workspace.id, manager); + await this._repairOrgGuests(scope, doc.workspace.org.id, manager); + return {status: 200}; } - const doc: Document = queryResult.data; - // Delete the doc and doc ACLs/groups. - const docGroups = doc.aclRules.map(docAcl => docAcl.group); - await manager.remove([doc, ...docGroups, ...doc.aclRules]); - // Update guests of the workspace and org after removing this doc. - await this._repairWorkspaceGuests(scope, doc.workspace.id, manager); - await this._repairOrgGuests(scope, doc.workspace.org.id, manager); - return {status: 200}; }); } @@ -1963,30 +2006,6 @@ export class HomeDBManager extends EventEmitter { return this._setDocumentRemovedAt(scope, null); } - /** - * Like `deleteDocument`, but for deleting a fork. - * - * NOTE: This is not a part of the API. It should only be called by the DocApi when - * deleting a fork. - */ - public async deleteFork(scope: DocScope): Promise> { - return await this._connection.transaction(async manager => { - const forkQuery = this._doc(scope, { - manager, - allowSpecialPermit: true - }); - const result = await forkQuery.getRawAndEntities(); - if (result.entities.length === 0) { - return { - status: 404, - errMessage: 'fork not found' - }; - } - await manager.remove(result.entities[0]); - return {status: 200}; - }); - } - // Fetches and provides a callback with the billingAccount so it may be updated within // a transaction. The billingAccount is saved after any changes applied in the callback. // Will throw an error if the user does not have access to the org's billingAccount. @@ -2425,7 +2444,7 @@ export class HomeDBManager extends EventEmitter { // have been flattened. if (forkId || snapshotId) { for (const user of users) { - this._setForkAccess({userId: user.id, forkUserId, snapshotId}, user); + this._setForkAccess(doc, {userId: user.id, forkUserId, snapshotId}, user); } } @@ -2870,9 +2889,6 @@ export class HomeDBManager extends EventEmitter { let query = this.org(scope, org, options) .leftJoinAndSelect('orgs.workspaces', 'workspaces') .leftJoinAndSelect('workspaces.docs', 'docs', this._onDoc(scope)) - .leftJoin('docs.forks', 'forks', this._onFork()) - .addSelect(['forks.id', 'forks.trunkId', 'forks.createdBy', 'forks.updatedAt']) - .setParameter('anonId', this.getAnonymousUserId()) .leftJoin('orgs.billingAccount', 'account') .leftJoin('account.product', 'product') .addSelect('product.features') @@ -2881,13 +2897,17 @@ export class HomeDBManager extends EventEmitter { // order the support org (aka Samples/Examples) after other ones. .orderBy('coalesce(orgs.owner_id = :supportId, false)') .setParameter('supportId', supportId) - .addOrderBy('(orgs.owner_id = :userId)', 'DESC') .setParameter('userId', userId) + .addOrderBy('(orgs.owner_id = :userId)', 'DESC') // For consistency of results, particularly in tests, order workspaces by name. .addOrderBy('workspaces.name') .addOrderBy('docs.created_at') .leftJoinAndSelect('orgs.owner', 'org_users'); + if (userId !== this.getAnonymousUserId()) { + query = this._addForks(userId, query); + } + // If merged org, we need to take some special steps. if (this.isMergedOrg(org)) { // Add information about owners of personal orgs. @@ -3158,6 +3178,21 @@ export class HomeDBManager extends EventEmitter { return qb.addSelect(`coalesce(${orgAlias}.owner_id = ${supportId}, false)`, alias); } + /** + * Makes sure that doc forks are available in query result. + */ + private _addForks(userId: number, qb: SelectQueryBuilder) { + return qb.leftJoin('docs.forks', 'forks', 'forks.created_by = :forkUserId') + .setParameter('forkUserId', userId) + .addSelect([ + 'forks.id', + 'forks.trunkId', + 'forks.createdBy', + 'forks.updatedAt', + 'forks.options' + ]); + } + /** * * Get the id of a special user, creating that user if it is not already present. @@ -3180,26 +3215,39 @@ export class HomeDBManager extends EventEmitter { * Modify an access level when the document is a fork. Here are the rules, as they * have evolved (the main constraint is that currently forks have no access info of * their own in the db). + * - If fork is a tutorial: + * - User ~USERID from the fork id is owner, all others have no access. * - If fork is a snapshot, all users are at most viewers. Else: * - If there is no ~USERID in fork id, then all viewers of trunk are owners of the fork. * - If there is a ~USERID in fork id, that user is owner, all others are at most viewers. */ - private _setForkAccess(ids: {userId: number, forkUserId?: number, snapshotId?: string}, + private _setForkAccess(doc: Document, + ids: {userId: number, forkUserId?: number, snapshotId?: string}, res: {access: roles.Role|null}) { - // Forks without a user id are editable by anyone with view access to the trunk. - if (ids.forkUserId === undefined && roles.canView(res.access)) { res.access = 'owners'; } - if (ids.forkUserId !== undefined) { - // A fork user id is known, so only that user should get to edit the fork. - if (ids.userId === ids.forkUserId) { - if (roles.canView(res.access)) { res.access = 'owners'; } + if (doc.type === 'tutorial') { + if (ids.userId === this.getPreviewerUserId()) { + res.access = 'viewers'; + } else if (ids.forkUserId && ids.forkUserId === ids.userId) { + res.access = 'owners'; } else { - // reduce to viewer if not already viewer - res.access = roles.getWeakestRole('viewers', res.access); + res.access = null; + } + } else { + // Forks without a user id are editable by anyone with view access to the trunk. + if (ids.forkUserId === undefined && roles.canView(res.access)) { res.access = 'owners'; } + if (ids.forkUserId !== undefined) { + // A fork user id is known, so only that user should get to edit the fork. + if (ids.userId === ids.forkUserId) { + if (roles.canView(res.access)) { res.access = 'owners'; } + } else { + // reduce to viewer if not already viewer + res.access = roles.getWeakestRole('viewers', res.access); + } + } + // Finally, if we are viewing a snapshot, we can't edit it. + if (ids.snapshotId) { + res.access = roles.getWeakestRole('viewers', res.access); } - } - // Finally, if we are viewing a snapshot, we can't edit it. - if (ids.snapshotId) { - res.access = roles.getWeakestRole('viewers', res.access); } } @@ -3463,6 +3511,40 @@ export class HomeDBManager extends EventEmitter { return query; } + /** + * Construct a QueryBuilder for a select query on a specific fork given by urlId. + * Provides options for running in a transaction. + */ + private _fork(scope: DocScope, options: QueryOptions = {}): SelectQueryBuilder { + // Extract the forkId from the urlId and use it to find the fork in the db. + const {forkId} = parseUrlId(scope.urlId); + let query = this._docs(options.manager) + .where('docs.id = :forkId', {forkId}); + + // Compute whether we have access to the fork. + if (options.allowSpecialPermit && scope.specialPermit?.docId) { + const {forkId: permitForkId} = parseUrlId(scope.specialPermit.docId); + query = query + .setParameter('permitForkId', permitForkId) + .addSelect( + 'docs.id = :permitForkId', + 'is_permitted' + ); + } else { + query = query + .setParameter('forkUserId', scope.userId) + .setParameter('forkAnonId', this.getAnonymousUserId()) + .addSelect( + // Access to forks is currently limited to the users that created them, with + // the exception of anonymous users, who have no access to their forks. + 'docs.created_by = :forkUserId AND docs.created_by <> :forkAnonId', + 'is_permitted' + ); + } + + return query; + } + private _workspaces(manager?: EntityManager) { return (manager || this._connection).createQueryBuilder() .select('workspaces') @@ -3491,13 +3573,6 @@ export class HomeDBManager extends EventEmitter { } } - /** - * Like _onDoc, but for joining forks. - */ - private _onFork() { - return 'forks.created_by = :userId AND forks.created_by <> :anonId'; - } - /** * Construct a QueryBuilder for a select query on a specific workspace given by * wsId. Provides options for running in a transaction and adding permission info. diff --git a/app/gen-server/migration/1678737195050-ForkIndexes.ts b/app/gen-server/migration/1678737195050-ForkIndexes.ts new file mode 100644 index 00000000..8677111b --- /dev/null +++ b/app/gen-server/migration/1678737195050-ForkIndexes.ts @@ -0,0 +1,21 @@ +import {MigrationInterface, QueryRunner, TableIndex} from "typeorm"; + +export class ForkIndexes1678737195050 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // HomeDBManager._onFork() references created_by in the ON clause. + await queryRunner.createIndex("docs", new TableIndex({ + name: "docs__created_by", + columnNames: ["created_by"] + })); + // HomeDBManager.getDocForks() references trunk_id in the WHERE clause. + await queryRunner.createIndex("docs", new TableIndex({ + name: "docs__trunk_id", + columnNames: ["trunk_id"] + })); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex("docs", "docs__created_by"); + await queryRunner.dropIndex("docs", "docs__trunk_id"); + } +} diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index a0455882..647ecd3c 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -231,8 +231,12 @@ export function attachAppEndpoint(options: AttachOptions): void { // Query DB for the doc metadata, to include in the page (as a pre-fetch of getDoc() call), // and to get fresh (uncached) access info. doc = await dbManager.getDoc({userId, org: mreq.org, urlId}); - const slug = getSlugIfNeeded(doc); + if (isAnonymousUser(mreq) && doc.type === 'tutorial') { + // Tutorials require users to be signed in. + throw new ApiError('You must be signed in to access a tutorial.', 403); + } + const slug = getSlugIfNeeded(doc); const slugMismatch = (req.params.slug || null) !== (slug || null); const preferredUrlId = doc.urlId || doc.id; if (urlId !== preferredUrlId || slugMismatch) { @@ -263,8 +267,8 @@ export function attachAppEndpoint(options: AttachOptions): void { // First check if anonymous user has access to this org. If so, we don't propose // that they log in. This is the same check made in redirectToLogin() middleware. const result = await dbManager.getOrg({userId: getUserId(mreq)}, mreq.org || null); - if (result.status !== 200) { - // Anonymous user does not have any access to this org, or to this doc. + if (result.status !== 200 || doc?.type === 'tutorial') { + // Anonymous user does not have any access to this org, doc, or tutorial. // Redirect to log in. return forceLogin(req, res, next); } diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index e25f5bca..384db5f2 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -9,7 +9,7 @@ import {SortFunc} from 'app/common/SortFunc'; import {Sort} from 'app/common/SortSpec'; import {MetaRowRecord} from 'app/common/TableData'; import {DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI'; -import {HomeDBManager, makeDocAuthResult, QueryResult} from 'app/gen-server/lib/HomeDBManager'; +import {HomeDBManager, makeDocAuthResult} from 'app/gen-server/lib/HomeDBManager'; import * as Types from "app/plugin/DocApiTypes"; import DocApiTypesTI from "app/plugin/DocApiTypes-ti"; import GristDataTI from 'app/plugin/GristData-ti'; @@ -784,6 +784,27 @@ export class DocWorkerApi { 'Content-Type': 'application/json', } }); + if (req.body.resetTutorialMetadata) { + const scope = getDocScope(req); + const tutorialTrunkId = options.sourceDocId; + await this._dbManager.connection.transaction(async (manager) => { + // Fetch the tutorial trunk doc so we can replace the tutorial doc's name. + const tutorialTrunk = await this._dbManager.getRawDocById(tutorialTrunkId, manager); + await this._dbManager.updateDocument( + scope, + { + name: tutorialTrunk.name, + options: { + tutorial: { + // For now, the only state we need to reset is the slide position. + lastSlideIndex: 0, + }, + }, + }, + manager + ); + }); + } } if (req.body.snapshotId) { options.snapshotId = String(req.body.snapshotId); @@ -1216,12 +1237,7 @@ export class DocWorkerApi { ]; await Promise.all(docsToDelete.map(docName => this._docManager.deleteDoc(null, docName, true))); // Permanently delete from database. - let query: QueryResult; - if (forkId) { - query = await this._dbManager.deleteFork({...scope, urlId: forkId}); - } else { - query = await this._dbManager.deleteDocument(scope); - } + const query = await this._dbManager.deleteDocument(scope); this._dbManager.checkQueryResult(query); await sendReply(req, res, query); } else { diff --git a/package.json b/package.json index 66062748..dce8eb2d 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@types/chai-as-promised": "7.1.0", "@types/content-disposition": "0.5.2", "@types/diff-match-patch": "1.0.32", + "@types/dompurify": "2.4.0", "@types/double-ended-queue": "2.1.0", "@types/express": "4.16.0", "@types/form-data": "2.2.1", @@ -59,6 +60,7 @@ "@types/jsonwebtoken": "7.2.8", "@types/lodash": "4.14.117", "@types/lru-cache": "5.1.1", + "@types/marked": "4.0.8", "@types/mime-types": "2.1.0", "@types/minio": "7.0.15", "@types/mocha": "5.2.5", @@ -130,6 +132,7 @@ "cookie-parser": "1.4.3", "csv": "4.0.0", "diff-match-patch": "1.0.5", + "dompurify": "3.0.0", "double-ended-queue": "2.1.0-0", "exceljs": "4.2.1", "express": "4.16.4", @@ -152,6 +155,7 @@ "knockout": "3.5.0", "locale-currency": "0.0.2", "lodash": "4.17.21", + "marked": "4.2.12", "minio": "7.0.32", "moment": "2.29.4", "moment-timezone": "0.5.35", diff --git a/static/icons/icons.css b/static/icons/icons.css index 6c960343..bfc303bf 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -80,8 +80,10 @@ --icon-Lock: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIj48cGF0aCBkPSJNMTIgNkwxMCA2IDEwIDRDMTAgMi44OTU0MzA1IDkuMTA0NTY5NSAyIDggMiA2Ljg5NTQzMDUgMiA2IDIuODk1NDMwNSA2IDRMNiA2IDQgNiA0IDRDNCAxLjc5MDg2MSA1Ljc5MDg2MSAwIDggMCAxMC4yMDkxMzkgMCAxMiAxLjc5MDg2MSAxMiA0TDEyIDZ6TTE0IDdMMiA3QzEuNDQ3NzE1MjUgNyAxIDcuNDQ3NzE1MjUgMSA4TDEgMTVDMSAxNS41NTIyODQ3IDEuNDQ3NzE1MjUgMTYgMiAxNkwxNCAxNkMxNC41NTIyODQ3IDE2IDE1IDE1LjU1MjI4NDcgMTUgMTVMMTUgOEMxNSA3LjQ0NzcxNTI1IDE0LjU1MjI4NDcgNyAxNCA3ek04IDEzQzYuODk1NDMwNSAxMyA2IDEyLjEwNDU2OTUgNiAxMSA2IDkuODk1NDMwNSA2Ljg5NTQzMDUgOSA4IDkgOS4xMDQ1Njk1IDkgMTAgOS44OTU0MzA1IDEwIDExIDEwIDEyLjEwNDU2OTUgOS4xMDQ1Njk1IDEzIDggMTN6Ii8+PC9nPjwvc3ZnPg=='); --icon-Log: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSw4LjUgTDYuNSw4LjUgQzYuMjIzODU3NjMsOC41IDYsOC4yNzYxNDIzNyA2LDggQzYsNy43MjM4NTc2MyA2LjIyMzg1NzYzLDcuNSA2LjUsNy41IEw3LjUsNy41IEw3LjUsMC41IEM3LjUsMC4yMjM4NTc2MjUgNy43MjM4NTc2MywwIDgsMCBDOC4yNzYxNDIzNywwIDguNSwwLjIyMzg1NzYyNSA4LjUsMC41IEw4LjUsMyBMOS41LDMgQzkuNzc2MTQyMzcsMyAxMCwzLjIyMzg1NzYzIDEwLDMuNSBDMTAsMy43NzYxNDIzNyA5Ljc3NjE0MjM3LDQgOS41LDQgTDguNSw0IEw4LjUsMTIgTDkuNSwxMiBDOS43NzYxNDIzNywxMiAxMCwxMi4yMjM4NTc2IDEwLDEyLjUgQzEwLDEyLjc3NjE0MjQgOS43NzYxNDIzNywxMyA5LjUsMTMgTDguNSwxMyBMOC41LDE1LjUgQzguNSwxNS43NzYxNDI0IDguMjc2MTQyMzcsMTYgOCwxNiBDNy43MjM4NTc2MywxNiA3LjUsMTUuNzc2MTQyNCA3LjUsMTUuNSBMNy41LDguNSBaIE0xMiwyIEwxMiw1IEwxNSw1IEwxNSwyIEwxMiwyIFogTTExLjUsMSBMMTUuNSwxIEMxNS43NzYxNDI0LDEgMTYsMS4yMjM4NTc2MyAxNiwxLjUgTDE2LDUuNSBDMTYsNS43NzYxNDIzNyAxNS43NzYxNDI0LDYgMTUuNSw2IEwxMS41LDYgQzExLjIyMzg1NzYsNiAxMSw1Ljc3NjE0MjM3IDExLDUuNSBMMTEsMS41IEMxMSwxLjIyMzg1NzYzIDExLjIyMzg1NzYsMSAxMS41LDEgWiBNMTIsMTEgTDEyLDE0IEwxNSwxNCBMMTUsMTEgTDEyLDExIFogTTExLjUsMTAgTDE1LjUsMTAgQzE1Ljc3NjE0MjQsMTAgMTYsMTAuMjIzODU3NiAxNiwxMC41IEwxNiwxNC41IEMxNiwxNC43NzYxNDI0IDE1Ljc3NjE0MjQsMTUgMTUuNSwxNSBMMTEuNSwxNSBDMTEuMjIzODU3NiwxNSAxMSwxNC43NzYxNDI0IDExLDE0LjUgTDExLDEwLjUgQzExLDEwLjIyMzg1NzYgMTEuMjIzODU3NiwxMCAxMS41LDEwIFogTTQsOS41IEw0LDYuNSBMMSw2LjUgTDEsOS41IEw0LDkuNSBaIE00LjUsMTAuNSBMMC41LDEwLjUgQzAuMjIzODU3NjI1LDEwLjUgMCwxMC4yNzYxNDI0IDAsMTAgTDguODgxNzg0MmUtMTYsNiBDOC44ODE3ODQyZS0xNiw1LjcyMzg1NzYzIDAuMjIzODU3NjI1LDUuNSAwLjUsNS41IEw0LjUsNS41IEM0Ljc3NjE0MjM3LDUuNSA1LDUuNzIzODU3NjMgNSw2IEw1LDEwIEM1LDEwLjI3NjE0MjQgNC43NzYxNDIzNywxMC41IDQuNSwxMC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Mail: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjUsMTMgQzE0Ljc3NTg1NzYsMTMgMTUsMTIuNzc1ODU3NiAxNSwxMi41IEwxNSwxLjUgQzE1LDEuMjI0MTQyMzcgMTQuNzc1ODU3NiwxIDE0LjUsMSBMMS41LDEgQzEuMjI0MTQyMzcsMSAxLDEuMjI0MTQyMzcgMSwxLjUgTDEsMTIuNSBDMSwxMi43NzU4NTc2IDEuMjI0MTQyMzcsMTMgMS41LDEzIEwxNC41LDEzIFogTTE0LjUsMTQgTDEuNSwxNCBDMC42NzE4NTc2MjUsMTQgMCwxMy4zMjgxNDI0IDAsMTIuNSBMMCwxLjUgQzAsMC42NzE4NTc2MjUgMC42NzE4NTc2MjUsOC44ODE3ODQyZS0xNiAxLjUsOC44ODE3ODQyZS0xNiBMMTQuNSw4Ljg4MTc4NDJlLTE2IEMxNS4zMjgxNDI0LDguODgxNzg0MmUtMTYgMTYsMC42NzE4NTc2MjUgMTYsMS41IEwxNiwxMi41IEMxNiwxMy4zMjgxNDI0IDE1LjMyODE0MjQsMTQgMTQuNSwxNCBaIE0xMy4xODMzODExLDMuMTEzMDIxMzUgQzEzLjM5NzEwMzUsMi45MzgxNTc1NiAxMy43MTIxMTQ5LDIuOTY5NjU4NyAxMy44ODY5Nzg2LDMuMTgzMzgxMSBDMTQuMDYxODQyNCwzLjM5NzEwMzUxIDE0LjAzMDM0MTMsMy43MTIxMTQ4NiAxMy44MTY2MTg5LDMuODg2OTc4NjUgTDguMzE2NjE4OSw4LjM4Njk3ODY1IEM4LjEzMjQzNTk1LDguNTM3NjczNzggNy44Njc1NjQwNSw4LjUzNzY3Mzc4IDcuNjgzMzgxMSw4LjM4Njk3ODY1IEwyLjE4MzM4MTEsMy44ODY5Nzg2NSBDMS45Njk2NTg3LDMuNzEyMTE0ODYgMS45MzgxNTc1NiwzLjM5NzEwMzUxIDIuMTEzMDIxMzUsMy4xODMzODExIEMyLjI4Nzg4NTE0LDIuOTY5NjU4NyAyLjYwMjg5NjQ5LDIuOTM4MTU3NTYgMi44MTY2MTg5LDMuMTEzMDIxMzUgTDgsNy4zNTM5Njk1MyBMMTMuMTgzMzgxMSwzLjExMzAyMTM1IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); + --icon-Maximize: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjUgMS41TDkuNSA2LjVNNi41IDkuNUwxLjUgMTQuNU04LjUgMS41SDE0LjVWNy41TTEuNSA4LjVWMTQuNUg3LjUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4='); --icon-Memo: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzE4NTFfMTYzMjIpIiBzdHJva2U9IiMwMDkwNTgiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTQuNS41SDEuNUMuOTQ4LjUuNS45NDguNSAxLjVWMTEuNUMuNSAxMi4wNTIuOTQ4IDEyLjUgMS41IDEyLjVINS41TDggMTUuNSAxMC41IDEyLjVIMTQuNUMxNS4wNTIgMTIuNSAxNS41IDEyLjA1MiAxNS41IDExLjVWMS41QzE1LjUuOTQ4IDE1LjA1Mi41IDE0LjUuNXpNMy41IDQuNUgxMi41TTMuNSA4LjVIMTIuNSIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwXzE4NTFfMTYzMjIiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4='); --icon-Message: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE1LjUgN0MxNS41IDMuNDEgMTIuMTQyIDAuNSA4IDAuNUMzLjg1OCAwLjUgMC41IDMuNDEgMC41IDdDMC41IDEwLjU5IDMuODU4IDEzLjUgOCAxMy41QzguNTI1IDEzLjUgOS4wMzcgMTMuNDUyIDkuNTMyIDEzLjM2M0wxMy41IDE1LjVWMTEuNDA5QzE0LjczOCAxMC4yNSAxNS41IDguNzA0IDE1LjUgN1oiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4='); + --icon-Minimize: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzI0MF83MjAwKSIgc3Ryb2tlPSIjZmZmIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNC41IDYuNUg5LjVWMS41TTE1LjUuNUw5LjUgNi41TTYuNSAxNC41VjkuNUgxLjVNLjUgMTUuNUw2LjUgOS41Ii8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDBfMjQwXzcyMDAiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4='); --icon-Minus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4='); --icon-MobileChat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUgOC41VjE0LjVDMTEuNSAxNC43NjUyIDExLjM5NDYgMTUuMDE5NiAxMS4yMDcxIDE1LjIwNzFDMTEuMDE5NiAxNS4zOTQ2IDEwLjc2NTIgMTUuNSAxMC41IDE1LjVIMi41QzIuMjM0NzggMTUuNSAxLjk4MDQzIDE1LjM5NDYgMS43OTI4OSAxNS4yMDcxQzEuNjA1MzYgMTUuMDE5NiAxLjUgMTQuNzY1MiAxLjUgMTQuNVYyLjVDMS41IDIuMjM0NzggMS42MDUzNiAxLjk4MDQzIDEuNzkyODkgMS43OTI4OUMxLjk4MDQzIDEuNjA1MzYgMi4yMzQ3OCAxLjUgMi41IDEuNUg0LjUiIHN0cm9rZT0iIzI2MjYzMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE0LjUgNi41SDkuNUw2LjUgOC41VjEuNUM2LjUgMS4yMzQ3OCA2LjYwNTM2IDAuOTgwNDMgNi43OTI4OSAwLjc5Mjg5M0M2Ljk4MDQzIDAuNjA1MzU3IDcuMjM0NzggMC41IDcuNSAwLjVIMTQuNUMxNC43NjUyIDAuNSAxNS4wMTk2IDAuNjA1MzU3IDE1LjIwNzEgMC43OTI4OTNDMTUuMzk0NiAwLjk4MDQzIDE1LjUgMS4yMzQ3OCAxNS41IDEuNVY1LjVDMTUuNSA1Ljc2NTIyIDE1LjM5NDYgNi4wMTk1NyAxNS4yMDcxIDYuMjA3MTFDMTUuMDE5NiA2LjM5NDY0IDE0Ljc2NTIgNi41IDE0LjUgNi41WiIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4='); --icon-MobileChat2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTggMEgxNUMxNS4yNjUyIDAgMTUuNTE5NiAwLjEwNTM1NyAxNS43MDcxIDAuMjkyODkzQzE1Ljg5NDYgMC40ODA0MyAxNiAwLjczNDc4NCAxNiAxVjVDMTYgNS4yNjUyMiAxNS44OTQ2IDUuNTE5NTcgMTUuNzA3MSA1LjcwNzExQzE1LjUxOTYgNS44OTQ2NCAxNS4yNjUyIDYgMTUgNkgxMEw3IDhWMUM3IDAuNzM0Nzg0IDcuMTA1MzYgMC40ODA0MyA3LjI5Mjg5IDAuMjkyODkzQzcuNDgwNDMgMC4xMDUzNTcgNy43MzQ3OCAwIDggMFYwWiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik0xMCA3VjEzSDNWNEg2VjFIM0MyLjQ2OTU3IDEgMS45NjA4NiAxLjIxMDcxIDEuNTg1NzkgMS41ODU3OUMxLjIxMDcxIDEuOTYwODYgMSAyLjQ2OTU3IDEgM1YxNEMxIDE0LjUzMDQgMS4yMTA3MSAxNS4wMzkxIDEuNTg1NzkgMTUuNDE0MkMxLjk2MDg2IDE1Ljc4OTMgMi40Njk1NyAxNiAzIDE2SDEwQzEwLjUzMDQgMTYgMTEuMDM5MSAxNS43ODkzIDExLjQxNDIgMTUuNDE0MkMxMS43ODkzIDE1LjAzOTEgMTIgMTQuNTMwNCAxMiAxNFY3SDEwWiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg=='); diff --git a/static/ui-icons/UI/Maximize.svg b/static/ui-icons/UI/Maximize.svg new file mode 100644 index 00000000..c5da3942 --- /dev/null +++ b/static/ui-icons/UI/Maximize.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/ui-icons/UI/Minimize.svg b/static/ui-icons/UI/Minimize.svg new file mode 100644 index 00000000..960b1672 --- /dev/null +++ b/static/ui-icons/UI/Minimize.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/test/fixtures/docs/DocTutorial.grist b/test/fixtures/docs/DocTutorial.grist new file mode 100644 index 00000000..5b3d01bf Binary files /dev/null and b/test/fixtures/docs/DocTutorial.grist differ diff --git a/test/nbrowser/DocTutorial.ts b/test/nbrowser/DocTutorial.ts new file mode 100644 index 00000000..94b6c04f --- /dev/null +++ b/test/nbrowser/DocTutorial.ts @@ -0,0 +1,310 @@ +import {DocCreationInfo} from 'app/common/DocListAPI'; +import {UserAPI} from 'app/common/UserAPI'; +import {assert, driver, Key} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {setupTestSuite} from 'test/nbrowser/testUtils'; + +describe('DocTutorial', function () { + this.timeout(30000); + setupTestSuite(); + + let doc: DocCreationInfo; + let api: UserAPI; + let session: gu.Session; + + const cleanup = setupTestSuite(); + + before(async () => { + session = await gu.session().teamSite.user('support').login(); + doc = await session.tempDoc(cleanup, 'DocTutorial.grist'); + api = session.createHomeApi(); + await api.updateDoc(doc.id, {type: 'tutorial'}); + await api.updateDocPermissions(doc.id, {users: { + 'anon@getgrist.com': 'viewers', + 'everyone@getgrist.com': 'viewers', + }}); + }); + + describe('when logged out', function () { + before(async () => { + session = await gu.session().anon.login(); + }); + + it('redirects user to log in', async function() { + await session.loadDoc(`/doc/${doc.id}`, false); + await gu.checkLoginPage(); + }); + }); + + describe('when logged in', function () { + let forkUrl: string; + + before(async () => { + session = await gu.session().teamSite.user('user1').login(); + }); + + afterEach(() => gu.checkForErrors()); + + it('creates a fork the first time the document is opened', async function() { + await session.loadDoc(`/doc/${doc.id}`); + await driver.wait(async () => { + forkUrl = await driver.getCurrentUrl(); + return /~/.test(forkUrl); + }); + }); + + it('shows a popup containing slides generated from the GristDocTutorial table', async function() { + assert.isTrue(await driver.findWait('.test-doc-tutorial-popup', 2000).isDisplayed()); + assert.equal(await driver.find('.test-doc-tutorial-popup-title').getText(), 'DocTutorial'); + assert.equal( + await driver.findWait('.test-doc-tutorial-popup h1', 2000).getText(), + 'Intro' + ); + assert.equal( + await driver.find('.test-doc-tutorial-popup p').getText(), + 'Welcome to the Grist Basics tutorial. We will cover the most important Grist ' + + 'concepts and features. Let’s get started.' + ); + }); + + it('is visible on all pages', async function() { + for (const page of ['access-rules', 'raw', 'code', 'settings']) { + await driver.find(`.test-tools-${page}`).click(); + assert.isTrue(await driver.find('.test-doc-tutorial-popup').isDisplayed()); + } + }); + + it('does not show the GristDocTutorial page or table', async function() { + assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2']); + await driver.find('.test-tools-raw').click(); + await driver.findWait('.test-raw-data-list', 1000); + await gu.waitForServer(); + assert.isFalse(await driver.findContent('.test-raw-data-table-id', + /GristDocTutorial/).isPresent()); + }); + + it('only allows users access to their own forks', async function() { + const otherSession = await gu.session().teamSite.user('user2').login(); + await driver.navigate().to(forkUrl); + assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/); + await otherSession.loadDoc(`/doc/${doc.id}`); + let otherForkUrl: string; + await driver.wait(async () => { + otherForkUrl = await driver.getCurrentUrl(); + return /~/.test(forkUrl); + }); + session = await gu.session().teamSite.user('user1').login(); + await driver.navigate().to(otherForkUrl!); + assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/); + await driver.navigate().to(forkUrl); + await gu.waitForDocToLoad(); + }); + + it('supports navigating to the next or previous slide', async function() { + await driver.findWait('.test-doc-tutorial-popup', 2000); + assert.isTrue(await driver.findWait('.test-doc-tutorial-popup-next', 2000).isDisplayed()); + assert.isFalse(await driver.find('.test-doc-tutorial-popup-previous').isDisplayed()); + await driver.find('.test-doc-tutorial-popup-next').click(); + assert.equal( + await driver.find('.test-doc-tutorial-popup h1').getText(), + 'Pages' + ); + assert.equal( + await driver.find('.test-doc-tutorial-popup h1 + p').getText(), + 'On the left-side panel is a list of pages which are views of your data. Right' + + ' now, there are two pages, Page 1 and Page 2. You are looking at Page 1.' + ); + assert.isTrue(await driver.find('.test-doc-tutorial-popup-next').isDisplayed()); + assert.isTrue(await driver.find('.test-doc-tutorial-popup-previous').isDisplayed()); + + await driver.find('.test-doc-tutorial-popup-previous').click(); + assert.equal( + await driver.find('.test-doc-tutorial-popup h1').getText(), + 'Intro' + ); + assert.equal( + await driver.find('.test-doc-tutorial-popup p').getText(), + 'Welcome to the Grist Basics tutorial. We will cover the most important Grist ' + + 'concepts and features. Let’s get started.' + ); + assert.isTrue(await driver.find('.test-doc-tutorial-popup-next').isDisplayed()); + assert.isFalse(await driver.find('.test-doc-tutorial-popup-previous').isDisplayed()); + }); + + it('supports navigating to a specific slide', async function() { + const slide3 = await driver.find('.test-doc-tutorial-popup-slide-3'); + assert.equal(await slide3.getAttribute('title'), 'Adding Columns and Rows'); + await slide3.click(); + await driver.find('.test-doc-tutorial-popup-slide-3').click(); + assert.equal( + await driver.find('.test-doc-tutorial-popup h1').getText(), + 'Adding Columns and Rows' + ); + assert.equal( + await driver.find('.test-doc-tutorial-popup p').getText(), + "You can add new columns to your table by clicking the ‘+’ icon" + + ' to the far right of your column headers.' + ); + + const slide1 = await driver.find('.test-doc-tutorial-popup-slide-1'); + assert.equal(await slide1.getAttribute('title'), 'Intro'); + await slide1.click(); + assert.equal( + await driver.find('.test-doc-tutorial-popup h1').getText(), + 'Intro' + ); + assert.equal( + await driver.find('.test-doc-tutorial-popup p').getText(), + 'Welcome to the Grist Basics tutorial. We will cover the most important Grist ' + + 'concepts and features. Let’s get started.' + ); + }); + + it('can open images in a lightbox', async function() { + await driver.find('.test-doc-tutorial-popup img').click(); + assert.isTrue(await driver.find('.test-doc-tutorial-lightbox').isDisplayed()); + assert.equal( + await driver.find('.test-doc-tutorial-lightbox-image').getAttribute('src'), + 'https://www.getgrist.com/wp-content/uploads/2023/03/Row-1-Intro.png' + ); + await driver.find('.test-doc-tutorial-lightbox-close').click(); + assert.isFalse(await driver.find('.test-doc-tutorial-lightbox').isPresent()); + }); + + it('can be minimized and maximized', async function() { + await driver.find('.test-doc-tutorial-popup-minimize-maximize').click(); + assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed()); + assert.isFalse(await driver.find('.test-doc-tutorial-popup-body').isPresent()); + assert.isFalse(await driver.find('.test-doc-tutorial-popup-footer').isPresent()); + + await driver.find('.test-doc-tutorial-popup-minimize-maximize').click(); + assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed()); + assert.isTrue(await driver.find('.test-doc-tutorial-popup-body').isDisplayed()); + assert.isTrue(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed()); + }); + + it('remembers the last slide the user had open', async function() { + await driver.find('.test-doc-tutorial-popup-slide-3').click(); + // There's a 1000ms debounce in place for updates to the last slide. + await driver.sleep(1000 + 250); + await gu.waitForServer(); + await driver.navigate().refresh(); + await gu.waitForDocToLoad(); + await driver.findWait('.test-doc-tutorial-popup', 2000); + assert.equal( + await driver.findWait('.test-doc-tutorial-popup h1', 2000).getText(), + 'Adding Columns and Rows' + ); + assert.equal( + await driver.find('.test-doc-tutorial-popup p').getText(), + "You can add new columns to your table by clicking the ‘+’ icon" + + ' to the far right of your column headers.' + ); + }); + + it('always opens the same fork whenever the document is opened', async function() { + assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Zane Rails']); + await gu.getCell(0, 1).click(); + await gu.sendKeys('Redacted', Key.ENTER); + await gu.waitForServer(); + await session.loadDoc(`/doc/${doc.id}`); + let currentUrl: string; + await driver.wait(async () => { + currentUrl = await driver.getCurrentUrl(); + return /~/.test(forkUrl); + }); + assert.equal(currentUrl!, forkUrl); + assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Redacted']); + }); + + it('skips starting or resuming a tutorial if the open mode is set to default', async function() { + await session.loadDoc(`/doc/${doc.id}/m/default`); + assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'GristDocTutorial']); + await driver.find('.test-tools-raw').click(); + await gu.waitForServer(); + assert.isTrue(await driver.findContentWait('.test-raw-data-table-id', + /GristDocTutorial/, 2000).isPresent()); + assert.isFalse(await driver.find('.test-doc-tutorial-popup').isPresent()); + }); + + it('can restart tutorials', async function() { + // Simulate that the tutorial has been updated since it was forked. + await api.updateDoc(doc.id, {name: 'DocTutorial V2'}); + await api.applyUserActions(doc.id, [['AddTable', 'NewTable', [{id: 'A'}]]]); + + // Load the current fork of the tutorial. + await driver.navigate().to(forkUrl); + await gu.waitForDocToLoad(); + await driver.findWait('.test-doc-tutorial-popup', 2000); + + // Check that the new table isn't in the fork. + assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2']); + assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Redacted']); + + // Restart the tutorial and wait for a new fork to be created. + await driver.find('.test-doc-tutorial-popup-restart').click(); + await driver.find('.test-modal-confirm').click(); + await gu.waitForServer(); + await driver.findWait('.test-doc-tutorial-popup', 2000); + + // Check that progress was reset. + assert.equal( + await driver.findWait('.test-doc-tutorial-popup h1', 2000).getText(), + 'Intro' + ); + assert.equal( + await driver.find('.test-doc-tutorial-popup p').getText(), + 'Welcome to the Grist Basics tutorial. We will cover the most important Grist ' + + 'concepts and features. Let’s get started.' + ); + + // Check that edits were reset. + assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Zane Rails']); + + // Check that changes made to the tutorial since the last fork are included. + assert.equal(await driver.find('.test-doc-tutorial-popup-title').getText(), + 'DocTutorial V2'); + assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'NewTable']); + }); + + it('redirects to the doc menu when finished', async function() { + await driver.find('.test-doc-tutorial-popup-slide-13').click(); + await driver.find('.test-doc-tutorial-popup-next').click(); + await driver.findWait('.test-dm-doclist', 2000); + }); + }); + + describe('without tutorial flag set', function () { + before(async () => { + await api.updateDoc(doc.id, {type: null}); + session = await gu.session().teamSite.user('user1').login(); + await session.loadDoc(`/doc/${doc.id}`); + }); + + afterEach(() => gu.checkForErrors()); + + it('shows the GristDocTutorial page and table', async function() { + assert.deepEqual(await gu.getPageNames(), + ['Page 1', 'Page 2', 'GristDocTutorial', 'NewTable']); + await gu.openPage('GristDocTutorial'); + assert.deepEqual( + await gu.getVisibleGridCells({cols: [1, 2], rowNums: [1]}), + [ + "# Intro\n\nWelcome to the Grist Basics tutorial. We will cover" + + " the most important Grist concepts and features. Let’s get" + + " started.\n\n![Grist Basics Tutorial](\n" + + "https://www.getgrist.com/wp-content/uploads/2023/03/Row-1-Intro.png)", + '', + ] + ); + await driver.find('.test-tools-raw').click(); + await gu.waitForServer(); + assert.isTrue(await driver.findContentWait('.test-raw-data-table-id', + /GristDocTutorial/, 2000).isPresent()); + }); + + it('does not show the tutorial popup', async function() { + assert.isFalse(await driver.find('.test-doc-tutorial-popup').isPresent()); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 4ca598e5..9db3e13c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -631,6 +631,13 @@ resolved "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.32.tgz" integrity sha512-bPYT5ECFiblzsVzyURaNhljBH2Gh1t9LowgUwciMrNAhFewLkHT2H0Mto07Y4/3KCOGZHRQll3CTtQZ0X11D/A== +"@types/dompurify@2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.4.0.tgz#fd9706392a88e0e0e6d367f3588482d817df0ab9" + integrity sha512-IDBwO5IZhrKvHFUl+clZxgf3hn2b/lU6H1KaBShPkQyGJUQ0xwebezIPSuiyGwfz1UzJWQl4M7BDxtHtCCPlTg== + dependencies: + "@types/trusted-types" "*" + "@types/double-ended-queue@2.1.0": version "2.1.0" resolved "https://registry.npmjs.org/@types/double-ended-queue/-/double-ended-queue-2.1.0.tgz" @@ -763,6 +770,11 @@ resolved "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.1.tgz" integrity sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw== +"@types/marked@4.0.8": + version "4.0.8" + resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.0.8.tgz#b316887ab3499d0a8f4c70b7bd8508f92d477955" + integrity sha512-HVNzMT5QlWCOdeuBsgXP8EZzKUf0+AXzN+sLmjvaB3ZlLqO+e4u0uXrdw9ub69wBKFs+c6/pA4r9sy6cCDvImw== + "@types/mime-types@2.1.0": version "2.1.0" resolved "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.0.tgz" @@ -904,6 +916,11 @@ resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz" integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== +"@types/trusted-types@*": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.3.tgz#a136f83b0758698df454e328759dbd3d44555311" + integrity sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g== + "@types/underscore@*": version "1.11.0" resolved "https://registry.npmjs.org/@types/underscore/-/underscore-1.11.0.tgz" @@ -3008,6 +3025,11 @@ domexception@^2.0.1: dependencies: webidl-conversions "^5.0.0" +dompurify@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.0.tgz#6adc6f918376d93419ed1ee35811850680027cba" + integrity sha512-0g/yr2IJn4nTbxwL785YxS7/AvvgGFJw6LLWP+BzWzB1+BYOqPUT9Hy0rXrZh5HLdHnxH72aDdzvC9SdTjsuaA== + dot-prop@^5.2.0: version "5.2.0" resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz" @@ -5671,6 +5693,18 @@ make-fetch-happen@^9.1.0: socks-proxy-agent "^6.0.0" ssri "^8.0.0" +marked@4.2.12: + version "4.2.12" + resolved "https://registry.yarnpkg.com/marked/-/marked-4.2.12.tgz#d69a64e21d71b06250da995dcd065c11083bebb5" + integrity sha512-yr8hSKa3Fv4D3jdZmtMMPghgVt6TWbk86WQaWhDloQjRSQhMMYCAro7jP7VDJrjjdV8pxVxMssXS8B8Y5DZ5aw== + +matcher@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz" + integrity sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng== + dependencies: + escape-string-regexp "^4.0.0" + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz"