(core) Add initial tutorials implementation

Summary:
Documents can now be flagged as tutorials, which causes them to display
Markdown-formatted slides from a special GristDocTutorial table. Tutorial
documents are forked on open, and remember the last slide a user was on.
They can be restarted too, which prepares a new fork of the tutorial.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3813
pull/472/head
George Gevoian 1 year ago
parent 210aa92eed
commit be8e13df64

@ -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<boolean>;
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<RawSectionOptions|null> = Observable.create(this, null);
private _activeContent: Computed<IDocPage|RawSectionOptions>;
private _docTutorialHolder = Holder.create<DocTutorial>(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;
}

@ -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<TableRec>;
public visibleTables: KoArray<TableRec>;
public rawDataTables: KoArray<TableRec>;
public rawSummaryTables: KoArray<TableRec>;
public allTableIds: KoArray<string>;
public visibleTableIds: KoArray<string>;
// 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<boolean> = 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<boolean>;
public readonly isTutorial: ko.Computed<boolean>;
// 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<MetaTableModel<any>>;
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<TName extends keyof SchemaTypes, TRow extends IRowModel<TName>>(
@ -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<TableRec> {
return createTablesArray(this.tables);
}
/**
* Returns an observable array of user tables, sorted by tableId, and excluding hidden/summary
* tables.
*/
private _createVisibleTablesArray(): KoArray<TableRec> {
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<TableRec> {
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<TableRec> {
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<TableRec>): KoArray<TableRec> {
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<TableRec>): KoArray<TableRec> {
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<TableRec>): KoArray<TableRec> {
return createTablesArray(tablesModel, r => isSummaryTable(tablesModel.tableData, r));
function isTutorialTable(tablesData: TableData, tableRef: UIRowId): boolean {
return tablesData.getValue(tableRef, 'tableId') === 'GristDocTutorial';
}

@ -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<boolean>;
userOverride: Observable<UserOverride|null>;
isBareFork: Observable<boolean>;
isTutorialTrunk: Observable<boolean>;
isTutorialFork: Observable<boolean>;
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,

@ -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();

@ -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;
}

@ -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<DocTutorialSlide[] | null> = 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%;
`);

@ -0,0 +1,16 @@
import {marked} from 'marked';
export const renderer = new marked.Renderer();
renderer.image = (href: string, text: string) => {
return `<div class="doc-tutorial-popup-thumbnail">
<img src="${href}" title="${text ?? ''}" />
<div class="doc-tutorial-popup-thumbnail-icon-wrapper">
<div class="doc-tutorial-popup-thumbnail-icon"></div>
</div>
</div>`;
};
renderer.link = (href: string, _title: string, text: string) => {
return `<a href="${href}" target="_blank">${text}</a>`;
};

@ -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");

@ -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'),

@ -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)),

@ -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);
}

@ -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",

@ -94,6 +94,7 @@ export function docBreadcrumbs(
isDocNameReadOnly?: BindableValue<boolean>,
isPageNameReadOnly?: BindableValue<boolean>,
isFork: Observable<boolean>,
isTutorialFork: Observable<boolean>,
isBareFork: Observable<boolean>,
isFiddle: Observable<boolean>,
isRecoveryMode: Observable<boolean>,
@ -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)) {

@ -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');

@ -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 = {

@ -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<ThemePrefs>;

@ -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;
}
/**

@ -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',
};

@ -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',
};

@ -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<keyof DocumentOptions>) {
if (this.options[key] === null) {

@ -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<QueryResult<Workspace>> {
public async getWorkspace(
scope: Scope,
wsId: number,
transaction?: EntityManager
): Promise<QueryResult<Workspace>> {
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<Document> {
public async getDocImpl(key: DocAuthKey, transaction?: EntityManager): Promise<Document> {
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<Document[]>(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<Document> {
public async getDoc(reqOrScope: Request | Scope, transaction?: EntityManager): Promise<Document> {
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<DocumentProperties>): Promise<QueryResult<number>> {
public async updateDocument(
scope: DocScope,
props: Partial<DocumentProperties>,
transaction?: EntityManager
): Promise<QueryResult<number>> {
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<Document>;
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<QueryResult<number>> {
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<QueryResult<number>> {
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<T>(userId: number, qb: SelectQueryBuilder<T>) {
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<Document> {
// 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.

@ -0,0 +1,21 @@
import {MigrationInterface, QueryRunner, TableIndex} from "typeorm";
export class ForkIndexes1678737195050 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
await queryRunner.dropIndex("docs", "docs__created_by");
await queryRunner.dropIndex("docs", "docs__trunk_id");
}
}

@ -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);
}

@ -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<number>;
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 {

@ -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",

@ -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==');

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5 1.5L9.5 6.5" stroke="white" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.5 9.5L1.5 14.5" stroke="white" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 1.5H14.5V7.5" stroke="white" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.5 8.5V14.5H7.5" stroke="white" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 563 B

@ -0,0 +1,13 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_240_7200)">
<path d="M14.5 6.5H9.5V1.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.5 0.5L9.5 6.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.5 14.5V9.5H1.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0.5 15.5L6.5 9.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_240_7200">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

@ -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. Lets 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. Lets 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. Lets 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. Lets 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. Lets 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());
});
});
});

@ -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"

Loading…
Cancel
Save