mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
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()) {
|
||||
return;
|
||||
}
|
||||
// Only start a tour when the full interface is showing, i.e. not when in embedded mode.
|
||||
// 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;
|
||||
}
|
||||
// If we have an active tour (or are in the process of starting one), don't start a new one.
|
||||
if (tourStarting || isTourActive()) {
|
||||
|
||||
const shouldStartTutorial = this.docModel.isTutorial();
|
||||
// Onboarding tours were not designed with mobile support in mind. Disable until fixed.
|
||||
if (isNarrowScreen() && !shouldStartTutorial) {
|
||||
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;
|
||||
// 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 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.
|
||||
* Return whether a table (identified by the rowId of its metadata record) is
|
||||
* the special GristDocTutorial table.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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();
|
||||
|
96
app/client/ui/DocTutorial.css
Normal file
96
app/client/ui/DocTutorial.css
Normal file
@ -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;
|
||||
}
|
639
app/client/ui/DocTutorial.ts
Normal file
639
app/client/ui/DocTutorial.ts
Normal file
@ -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%;
|
||||
`);
|
16
app/client/ui/DocTutorialRenderer.ts
Normal file
16
app/client/ui/DocTutorialRenderer.ts
Normal file
@ -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)),
|
||||
|
26
app/client/ui/sanitizeHTML.ts
Normal file
26
app/client/ui/sanitizeHTML.ts
Normal file
@ -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.
|
||||
|
21
app/gen-server/migration/1678737195050-ForkIndexes.ts
Normal file
21
app/gen-server/migration/1678737195050-ForkIndexes.ts
Normal file
@ -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==');
|
||||
|
6
static/ui-icons/UI/Maximize.svg
Normal file
6
static/ui-icons/UI/Maximize.svg
Normal file
@ -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 |
13
static/ui-icons/UI/Minimize.svg
Normal file
13
static/ui-icons/UI/Minimize.svg
Normal file
@ -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 |
BIN
test/fixtures/docs/DocTutorial.grist
vendored
Normal file
BIN
test/fixtures/docs/DocTutorial.grist
vendored
Normal file
Binary file not shown.
310
test/nbrowser/DocTutorial.ts
Normal file
310
test/nbrowser/DocTutorial.ts
Normal file
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
34
yarn.lock
34
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"
|
||||
|
Loading…
Reference in New Issue
Block a user