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 {App} from 'app/client/ui/App';
|
||||||
import {DocHistory} from 'app/client/ui/DocHistory';
|
import {DocHistory} from 'app/client/ui/DocHistory';
|
||||||
import {startDocTour} from "app/client/ui/DocTour";
|
import {startDocTour} from "app/client/ui/DocTour";
|
||||||
|
import {DocTutorial} from 'app/client/ui/DocTutorial';
|
||||||
import {isTourActive} from "app/client/ui/OnBoardingPopups";
|
import {isTourActive} from "app/client/ui/OnBoardingPopups";
|
||||||
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
||||||
import {linkFromId, selectBy} from 'app/client/ui/selectBy';
|
import {linkFromId, selectBy} from 'app/client/ui/selectBy';
|
||||||
@ -162,9 +163,6 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
|
|
||||||
public readonly userOrgPrefs = getUserOrgPrefsObs(this.docPageModel.appModel);
|
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;
|
public readonly behavioralPromptsManager = this.docPageModel.appModel.behavioralPromptsManager;
|
||||||
// One of the section can be expanded (as requested from the Layout), we will
|
// 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
|
// 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 _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours');
|
||||||
private _rawSectionOptions: Observable<RawSectionOptions|null> = Observable.create(this, null);
|
private _rawSectionOptions: Observable<RawSectionOptions|null> = Observable.create(this, null);
|
||||||
private _activeContent: Computed<IDocPage|RawSectionOptions>;
|
private _activeContent: Computed<IDocPage|RawSectionOptions>;
|
||||||
|
private _docTutorialHolder = Holder.create<DocTutorial>(this);
|
||||||
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -204,7 +203,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
super();
|
super();
|
||||||
console.log("RECEIVED DOC RESPONSE", openDocResponse);
|
console.log("RECEIVED DOC RESPONSE", openDocResponse);
|
||||||
this.docData = new DocData(this.docComm, openDocResponse.doc);
|
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.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm);
|
||||||
this.docPluginManager = new DocPluginManager(plugins,
|
this.docPluginManager = new DocPluginManager(plugins,
|
||||||
app.topAppModel.getUntrustedContentOrigin(), this.docComm, app.clientScope);
|
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.
|
// Maintain the MetaRowModel for the global document info, including docId and peers.
|
||||||
this.docInfo = this.docModel.docInfoRow;
|
this.docInfo = this.docModel.docInfoRow;
|
||||||
|
|
||||||
this.hasDocTour = Computed.create(this, use =>
|
|
||||||
use(this.docModel.visibleTableIds.getObservable()).includes('GristDocTour'));
|
|
||||||
|
|
||||||
const defaultViewId = this.docInfo.newDefaultViewId;
|
const defaultViewId = this.docInfo.newDefaultViewId;
|
||||||
|
|
||||||
// Grainjs observable for current view id, which may be a string such as 'code'.
|
// 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 isStartingTourOrTutorial = false;
|
||||||
let tourStarting = false;
|
|
||||||
this.autoDispose(subscribe(urlState().state, async (_use, state) => {
|
this.autoDispose(subscribe(urlState().state, async (_use, state) => {
|
||||||
// Onboarding tours were not designed with mobile support in mind. Disable until fixed.
|
// Only start a tour or tutorial when the full interface is showing, i.e. not when in
|
||||||
if (isNarrowScreen()) {
|
// embedded mode.
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Only start a tour when the full interface is showing, i.e. not when in embedded mode.
|
|
||||||
if (state.params?.style === 'light') {
|
if (state.params?.style === 'light') {
|
||||||
return;
|
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;
|
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) {
|
// If we have an active tour or tutorial (or are in the process of starting one), don't start
|
||||||
tourStarting = true;
|
// 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 {
|
try {
|
||||||
await this._waitForView();
|
await this._waitForView();
|
||||||
|
|
||||||
@ -316,14 +316,19 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
await urlState().pushUrl({welcomeTour: false, docTour: false},
|
await urlState().pushUrl({welcomeTour: false, docTour: false},
|
||||||
{replace: true, avoidReload: true});
|
{replace: true, avoidReload: true});
|
||||||
|
|
||||||
if (!docTour) {
|
if (shouldStartTutorial) {
|
||||||
startWelcomeTour(() => this._showGristTour.set(false));
|
await DocTutorial.create(this._docTutorialHolder, this).start();
|
||||||
} else {
|
} else if (shouldStartDocTour) {
|
||||||
const onFinishCB = () => (autoStartDocTour && markAsSeen(this._seenDocTours, this.docId()));
|
const onFinishCB = () => (
|
||||||
|
!this._seenDocTours.get()?.includes(this.docId())
|
||||||
|
&& markAsSeen(this._seenDocTours, this.docId())
|
||||||
|
);
|
||||||
await startDocTour(this.docData, this.docComm, onFinishCB);
|
await startDocTour(this.docData, this.docComm, onFinishCB);
|
||||||
|
} else {
|
||||||
|
startWelcomeTour(() => this._showGristTour.set(false));
|
||||||
}
|
}
|
||||||
} finally {
|
} 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 {
|
private _shouldAutoStartWelcomeTour(): boolean {
|
||||||
// When both a docTour and grist welcome tour are available, show only the docTour, leaving
|
// If a doc tutorial or tour are available, leave the welcome tour for another
|
||||||
// the welcome tour for another doc (e.g. a new one).
|
// doc (e.g. a new one).
|
||||||
if (this.hasDocTour.get()) {
|
if (this.docModel.isTutorial() || this.docModel.hasDocTour()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,13 +19,16 @@ import * as koArray from 'app/client/lib/koArray';
|
|||||||
import * as koUtil from 'app/client/lib/koUtil';
|
import * as koUtil from 'app/client/lib/koUtil';
|
||||||
import DataTableModel from 'app/client/models/DataTableModel';
|
import DataTableModel from 'app/client/models/DataTableModel';
|
||||||
import {DocData} from 'app/client/models/DocData';
|
import {DocData} from 'app/client/models/DocData';
|
||||||
|
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import MetaRowModel from 'app/client/models/MetaRowModel';
|
import MetaRowModel from 'app/client/models/MetaRowModel';
|
||||||
import MetaTableModel from 'app/client/models/MetaTableModel';
|
import MetaTableModel from 'app/client/models/MetaTableModel';
|
||||||
import * as rowset from 'app/client/models/rowset';
|
import * as rowset from 'app/client/models/rowset';
|
||||||
|
import {TableData} from 'app/client/models/TableData';
|
||||||
import {isHiddenTable, isSummaryTable} from 'app/common/isHiddenTable';
|
import {isHiddenTable, isSummaryTable} from 'app/common/isHiddenTable';
|
||||||
import {RowFilterFunc} from 'app/common/RowFilterFunc';
|
import {RowFilterFunc} from 'app/common/RowFilterFunc';
|
||||||
import {schema, SchemaTypes} from 'app/common/schema';
|
import {schema, SchemaTypes} from 'app/common/schema';
|
||||||
|
import {UIRowId} from 'app/common/UIRowId';
|
||||||
|
|
||||||
import {ACLRuleRec, createACLRuleRec} from 'app/client/models/entities/ACLRuleRec';
|
import {ACLRuleRec, createACLRuleRec} from 'app/client/models/entities/ACLRuleRec';
|
||||||
import {ColumnRec, createColumnRec} from 'app/client/models/entities/ColumnRec';
|
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 {CellRec, createCellRec} from 'app/client/models/entities/CellRec';
|
||||||
import {RefListValue} from 'app/common/gristTypes';
|
import {RefListValue} from 'app/common/gristTypes';
|
||||||
import {decodeObject} from 'app/plugin/objtypes';
|
import {decodeObject} from 'app/plugin/objtypes';
|
||||||
|
import { toKo } from 'grainjs';
|
||||||
|
|
||||||
// Re-export all the entity types available. The recommended usage is like this:
|
// Re-export all the entity types available. The recommended usage is like this:
|
||||||
// import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
|
// import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
|
||||||
@ -129,10 +133,12 @@ export class DocModel {
|
|||||||
|
|
||||||
public docInfoRow: DocInfoRec;
|
public docInfoRow: DocInfoRec;
|
||||||
|
|
||||||
|
public allTables: KoArray<TableRec>;
|
||||||
public visibleTables: KoArray<TableRec>;
|
public visibleTables: KoArray<TableRec>;
|
||||||
public rawDataTables: KoArray<TableRec>;
|
public rawDataTables: KoArray<TableRec>;
|
||||||
public rawSummaryTables: KoArray<TableRec>;
|
public rawSummaryTables: KoArray<TableRec>;
|
||||||
|
|
||||||
|
public allTableIds: KoArray<string>;
|
||||||
public visibleTableIds: KoArray<string>;
|
public visibleTableIds: KoArray<string>;
|
||||||
|
|
||||||
// A mapping from tableId to DataTableModel for user-defined tables.
|
// 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
|
// Flag for tracking whether document is in formula-editing mode
|
||||||
public editingFormula: ko.Observable<boolean> = ko.observable(false);
|
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
|
// 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.
|
// 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 showDocTourTable: boolean = (urlState().state.get().docPage === 'GristDocTour');
|
||||||
|
|
||||||
|
public showDocTutorialTable: boolean = !this._docPageModel.isTutorialFork.get();
|
||||||
|
|
||||||
// List of all the metadata tables.
|
// List of all the metadata tables.
|
||||||
private _metaTables: Array<MetaTableModel<any>>;
|
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 all the metadata tables, load their data (and create the RowModels).
|
||||||
for (const model of this._metaTables) {
|
for (const model of this._metaTables) {
|
||||||
model.loadData();
|
model.loadData();
|
||||||
@ -166,13 +179,20 @@ export class DocModel {
|
|||||||
|
|
||||||
this.docInfoRow = this.docInfo.getRowModel(1);
|
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.
|
// An observable array of user-visible tables, sorted by tableId, excluding summary tables.
|
||||||
// This is a publicly exposed member.
|
// 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.
|
// Observable arrays of raw data and summary tables, sorted by tableId.
|
||||||
this.rawDataTables = createRawDataTablesArray(this.tables);
|
this.rawDataTables = this._createRawDataTablesArray();
|
||||||
this.rawSummaryTables = createRawSummaryTablesArray(this.tables);
|
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.
|
// An observable array of user-visible tableIds. A shortcut mapped from visibleTables.
|
||||||
const visibleTableIds = ko.computed(() => this.visibleTables.all().map(t => t.tableId()));
|
const visibleTableIds = ko.computed(() => this.visibleTables.all().map(t => t.tableId()));
|
||||||
@ -206,6 +226,12 @@ export class DocModel {
|
|||||||
return pagesToShow.filter(p => !hide(p));
|
return pagesToShow.filter(p => !hide(p));
|
||||||
});
|
});
|
||||||
this.visibleDocPages = ko.computed(() => allPages.all().filter(p => !p.isHidden()));
|
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>>(
|
private _metaTableModel<TName extends keyof SchemaTypes, TRow extends IRowModel<TName>>(
|
||||||
@ -240,6 +266,42 @@ export class DocModel {
|
|||||||
delete this.dataTables[tid];
|
delete this.dataTables[tid];
|
||||||
this.dataTablesByRef.delete(tableMetaRow.getRowId());
|
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
|
* Return whether a table (identified by the rowId of its metadata record) is
|
||||||
* tables.
|
* the special GristDocTutorial table.
|
||||||
*/
|
*/
|
||||||
function createVisibleTablesArray(tablesModel: MetaTableModel<TableRec>): KoArray<TableRec> {
|
function isTutorialTable(tablesData: TableData, tableRef: UIRowId): boolean {
|
||||||
return createTablesArray(tablesModel, r => !isHiddenTable(tablesModel.tableData, r));
|
return tablesData.getValue(tableRef, 'tableId') === 'GristDocTutorial';
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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));
|
|
||||||
}
|
}
|
||||||
|
@ -19,7 +19,7 @@ import {delay} from 'app/common/delay';
|
|||||||
import {OpenDocMode, UserOverride} from 'app/common/DocListAPI';
|
import {OpenDocMode, UserOverride} from 'app/common/DocListAPI';
|
||||||
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
|
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
|
||||||
import {Product} from 'app/common/Features';
|
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 {getReconnectTimeout} from 'app/common/gutil';
|
||||||
import {canEdit, isOwner} from 'app/common/roles';
|
import {canEdit, isOwner} from 'app/common/roles';
|
||||||
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
|
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
|
||||||
@ -39,6 +39,8 @@ export interface DocInfo extends Document {
|
|||||||
userOverride: UserOverride|null;
|
userOverride: UserOverride|null;
|
||||||
isBareFork: boolean; // a document created without logging in, which is treated as a
|
isBareFork: boolean; // a document created without logging in, which is treated as a
|
||||||
// fork without an original.
|
// fork without an original.
|
||||||
|
isTutorialTrunk: boolean;
|
||||||
|
isTutorialFork: boolean;
|
||||||
idParts: UrlIdParts;
|
idParts: UrlIdParts;
|
||||||
openMode: OpenDocMode;
|
openMode: OpenDocMode;
|
||||||
}
|
}
|
||||||
@ -70,6 +72,8 @@ export interface DocPageModel {
|
|||||||
isRecoveryMode: Observable<boolean>;
|
isRecoveryMode: Observable<boolean>;
|
||||||
userOverride: Observable<UserOverride|null>;
|
userOverride: Observable<UserOverride|null>;
|
||||||
isBareFork: Observable<boolean>;
|
isBareFork: Observable<boolean>;
|
||||||
|
isTutorialTrunk: Observable<boolean>;
|
||||||
|
isTutorialFork: Observable<boolean>;
|
||||||
|
|
||||||
importSources: ImportSource[];
|
importSources: ImportSource[];
|
||||||
|
|
||||||
@ -120,6 +124,10 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
|||||||
(use, doc) => doc ? doc.isRecoveryMode : false);
|
(use, doc) => doc ? doc.isRecoveryMode : false);
|
||||||
public readonly userOverride = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.userOverride : null);
|
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 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[] = [];
|
public readonly importSources: ImportSource[] = [];
|
||||||
|
|
||||||
@ -226,12 +234,22 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Replace the URL without reloading the doc.
|
// 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 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.
|
// We preemptively update _openerDocKey so that the URL update doesn't trigger a reload.
|
||||||
this._openerDocKey = this._getDocKey(nextState);
|
this._openerDocKey = this._getDocKey(nextState);
|
||||||
return urlState().pushUrl(nextState, {avoidReload: true, ...options});
|
return urlState().pushUrl(nextState, {avoidReload: true, replace: replaceUrl});
|
||||||
}
|
}
|
||||||
|
|
||||||
public offerRecovery(err: Error) {
|
public offerRecovery(err: Error) {
|
||||||
@ -283,15 +301,41 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
|||||||
const gristDocModulePromise = loadGristDoc();
|
const gristDocModulePromise = loadGristDoc();
|
||||||
|
|
||||||
const docResponse = await retryOnNetworkError(flow, getDoc.bind(null, this._api, urlId));
|
const docResponse = await retryOnNetworkError(flow, getDoc.bind(null, this._api, urlId));
|
||||||
const doc = buildDocInfo(docResponse, urlOpenMode);
|
|
||||||
flow.checkIfCancelled();
|
flow.checkIfCancelled();
|
||||||
|
|
||||||
if (doc.urlId && doc.urlId !== urlId) {
|
let doc = buildDocInfo(docResponse, urlOpenMode);
|
||||||
// Replace the URL to reflect the canonical urlId.
|
if (doc.isTutorialTrunk) {
|
||||||
await this.updateUrlNoReload(doc.urlId, doc.openMode, {replace: true});
|
// 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
|
// Maintain a connection to doc-worker while opening a document. After it's opened, the DocComm
|
||||||
// object created by GristDoc will maintain the connection.
|
// 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.
|
// The current document has been forked, and should now be referred to using a new docId.
|
||||||
const currentDoc = this.currentDoc.get();
|
const currentDoc = this.currentDoc.get();
|
||||||
if (currentDoc) {
|
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');
|
await this.updateCurrentDoc(newUrlId, 'default');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -396,6 +441,8 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
|
|||||||
|
|
||||||
const isPreFork = (openMode === 'fork');
|
const isPreFork = (openMode === 'fork');
|
||||||
const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE;
|
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;
|
const isEditable = canEdit(doc.access) || isPreFork;
|
||||||
return {
|
return {
|
||||||
...doc,
|
...doc,
|
||||||
@ -404,6 +451,8 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
|
|||||||
userOverride: null, // ditto.
|
userOverride: null, // ditto.
|
||||||
isPreFork,
|
isPreFork,
|
||||||
isBareFork,
|
isBareFork,
|
||||||
|
isTutorialTrunk,
|
||||||
|
isTutorialFork,
|
||||||
isReadonly: !isEditable,
|
isReadonly: !isEditable,
|
||||||
idParts,
|
idParts,
|
||||||
openMode,
|
openMode,
|
||||||
|
@ -14,6 +14,7 @@ export function createPageRec(this: PageRec, docModel: DocModel): void {
|
|||||||
// Page is hidden when any of this is true:
|
// Page is hidden when any of this is true:
|
||||||
// - It has an empty name (or no name at all)
|
// - It has an empty name (or no name at all)
|
||||||
// - It is GristDocTour (unless user wants to see it)
|
// - 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
|
// - It is a page generated for a hidden table TODO: Follow up - don't create
|
||||||
// pages for hidden tables.
|
// pages for hidden tables.
|
||||||
// This is used currently only the left panel, to hide pages from the user.
|
// 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);
|
const primaryTable = tables.find(t => t.primaryViewId() === viewId);
|
||||||
return !!primaryTable && primaryTable.tableId()?.startsWith("GristHidden_");
|
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(() => {
|
this.isHidden = ko.pureComputed(() => {
|
||||||
return this.isCensored() || this.isSpecial();
|
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),
|
menuOriginal(doc, appModel, true),
|
||||||
menuExports(doc, pageModel),
|
menuExports(doc, pageModel),
|
||||||
], {buttonAction: backToCurrent});
|
], {buttonAction: backToCurrent});
|
||||||
|
} else if (doc.isTutorialFork) {
|
||||||
|
return null;
|
||||||
} else if (doc.isPreFork || doc.isBareFork) {
|
} else if (doc.isPreFork || doc.isBareFork) {
|
||||||
// A new unsaved document, or a fiddle, or a public example.
|
// A new unsaved document, or a fiddle, or a public example.
|
||||||
const saveActionTitle = doc.isBareFork ? t("Save Document") : t("Save Copy");
|
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.
|
// 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(
|
cssSplitPageEntry(
|
||||||
cssPageEntryMain(
|
cssPageEntryMain(
|
||||||
cssPageLink(cssPageIcon('Page'),
|
cssPageLink(cssPageIcon('Page'),
|
||||||
|
@ -84,6 +84,7 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
|
|||||||
isFork: pageModel.isFork,
|
isFork: pageModel.isFork,
|
||||||
isBareFork: pageModel.isBareFork,
|
isBareFork: pageModel.isBareFork,
|
||||||
isRecoveryMode: pageModel.isRecoveryMode,
|
isRecoveryMode: pageModel.isRecoveryMode,
|
||||||
|
isTutorialFork: pageModel.isTutorialFork,
|
||||||
isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork)),
|
isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork)),
|
||||||
isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)),
|
isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)),
|
||||||
isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)),
|
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" |
|
"Lock" |
|
||||||
"Log" |
|
"Log" |
|
||||||
"Mail" |
|
"Mail" |
|
||||||
|
"Maximize" |
|
||||||
"Memo" |
|
"Memo" |
|
||||||
"Message" |
|
"Message" |
|
||||||
|
"Minimize" |
|
||||||
"Minus" |
|
"Minus" |
|
||||||
"MobileChat" |
|
"MobileChat" |
|
||||||
"MobileChat2" |
|
"MobileChat2" |
|
||||||
@ -214,8 +216,10 @@ export const IconList: IconName[] = ["ChartArea",
|
|||||||
"Lock",
|
"Lock",
|
||||||
"Log",
|
"Log",
|
||||||
"Mail",
|
"Mail",
|
||||||
|
"Maximize",
|
||||||
"Memo",
|
"Memo",
|
||||||
"Message",
|
"Message",
|
||||||
|
"Minimize",
|
||||||
"Minus",
|
"Minus",
|
||||||
"MobileChat",
|
"MobileChat",
|
||||||
"MobileChat2",
|
"MobileChat2",
|
||||||
|
@ -94,6 +94,7 @@ export function docBreadcrumbs(
|
|||||||
isDocNameReadOnly?: BindableValue<boolean>,
|
isDocNameReadOnly?: BindableValue<boolean>,
|
||||||
isPageNameReadOnly?: BindableValue<boolean>,
|
isPageNameReadOnly?: BindableValue<boolean>,
|
||||||
isFork: Observable<boolean>,
|
isFork: Observable<boolean>,
|
||||||
|
isTutorialFork: Observable<boolean>,
|
||||||
isBareFork: Observable<boolean>,
|
isBareFork: Observable<boolean>,
|
||||||
isFiddle: Observable<boolean>,
|
isFiddle: Observable<boolean>,
|
||||||
isRecoveryMode: Observable<boolean>,
|
isRecoveryMode: Observable<boolean>,
|
||||||
@ -140,7 +141,7 @@ export function docBreadcrumbs(
|
|||||||
if (options.isSnapshot && use(options.isSnapshot)) {
|
if (options.isSnapshot && use(options.isSnapshot)) {
|
||||||
return cssTag(t("snapshot"), testId('snapshot-tag'));
|
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'));
|
return cssTag(t("unsaved"), testId('unsaved-tag'));
|
||||||
}
|
}
|
||||||
if (use(options.isRecoveryMode)) {
|
if (use(options.isRecoveryMode)) {
|
||||||
|
@ -694,6 +694,13 @@ export const theme = {
|
|||||||
colors.mediumGreyOpaque),
|
colors.mediumGreyOpaque),
|
||||||
datePickerRangeBgHover: new CustomProp('theme-date-picker-range-bg-hover', undefined,
|
datePickerRangeBgHover: new CustomProp('theme-date-picker-range-bg-hover', undefined,
|
||||||
colors.darkGrey),
|
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');
|
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-start-end-bg-hover": "string",
|
||||||
"date-picker-range-bg": "string",
|
"date-picker-range-bg": "string",
|
||||||
"date-picker-range-bg-hover": "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 = {
|
const exportedTypeSuite: t.ITypeSuite = {
|
||||||
|
@ -451,6 +451,11 @@ export interface ThemeColors {
|
|||||||
'date-picker-range-start-end-bg-hover': string;
|
'date-picker-range-start-end-bg-hover': string;
|
||||||
'date-picker-range-bg': string;
|
'date-picker-range-bg': string;
|
||||||
'date-picker-range-bg-hover': 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>;
|
export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>;
|
||||||
|
@ -119,6 +119,11 @@ export interface DocumentOptions {
|
|||||||
openMode?: OpenDocMode|null;
|
openMode?: OpenDocMode|null;
|
||||||
externalId?: string|null; // A slot for storing an externally maintained id.
|
externalId?: string|null; // A slot for storing an externally maintained id.
|
||||||
// Not used in grist-core, but handy for Electron app.
|
// Not used in grist-core, but handy for Electron app.
|
||||||
|
tutorial?: TutorialMetadata|null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TutorialMetadata {
|
||||||
|
lastSlideIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentProperties extends CommonProperties {
|
export interface DocumentProperties extends CommonProperties {
|
||||||
@ -129,7 +134,7 @@ export interface DocumentProperties extends CommonProperties {
|
|||||||
options: DocumentOptions|null;
|
options: DocumentOptions|null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const documentPropertyKeys = [...commonPropertyKeys, 'isPinned', 'urlId', 'options'];
|
export const documentPropertyKeys = [...commonPropertyKeys, 'isPinned', 'urlId', 'options', 'type'];
|
||||||
|
|
||||||
export interface Document extends DocumentProperties {
|
export interface Document extends DocumentProperties {
|
||||||
id: string;
|
id: string;
|
||||||
@ -143,6 +148,7 @@ export interface Fork {
|
|||||||
id: string;
|
id: string;
|
||||||
trunkId: string;
|
trunkId: string;
|
||||||
updatedAt: string; // ISO date string
|
updatedAt: string; // ISO date string
|
||||||
|
options: DocumentOptions|null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-core options for a user.
|
// Non-core options for a user.
|
||||||
@ -241,8 +247,21 @@ export interface OrgError {
|
|||||||
* (e.g. a fork) or from a snapshot.
|
* (e.g. a fork) or from a snapshot.
|
||||||
*/
|
*/
|
||||||
export interface DocReplacementOptions {
|
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-start-end-bg-hover': '#8F8F8F',
|
||||||
'date-picker-range-bg': '#57575F',
|
'date-picker-range-bg': '#57575F',
|
||||||
'date-picker-range-bg-hover': '#7F7F7F',
|
'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-start-end-bg-hover': '#CFCFCF',
|
||||||
'date-picker-range-bg': '#EEEEEE',
|
'date-picker-range-bg': '#EEEEEE',
|
||||||
'date-picker-range-bg-hover': '#D9D9D9',
|
'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})
|
@Column({name: 'trunk_id', type: 'text', nullable: true})
|
||||||
public trunkId: string|null;
|
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)
|
@ManyToOne(_type => Document, document => document.forks)
|
||||||
@JoinColumn({name: 'trunk_id'})
|
@JoinColumn({name: 'trunk_id'})
|
||||||
public trunk: Document|null;
|
public trunk: Document|null;
|
||||||
@ -123,6 +120,19 @@ export class Document extends Resource {
|
|||||||
if (props.options.externalId !== undefined) {
|
if (props.options.externalId !== undefined) {
|
||||||
this.options.externalId = props.options.externalId;
|
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.
|
// Normalize so that null equates with absence.
|
||||||
for (const key of Object.keys(this.options) as Array<keyof DocumentOptions>) {
|
for (const key of Object.keys(this.options) as Array<keyof DocumentOptions>) {
|
||||||
if (this.options[key] === null) {
|
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
|
* Returns a QueryResult for the workspace with the given workspace id. The workspace
|
||||||
* includes nested Docs.
|
* 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;
|
const {userId} = scope;
|
||||||
let queryBuilder = this._workspaces()
|
let queryBuilder = this._workspaces(transaction)
|
||||||
.where('workspaces.id = :wsId', {wsId})
|
.where('workspaces.id = :wsId', {wsId})
|
||||||
// Nest the docs within the workspace object
|
// Nest the docs within the workspace object
|
||||||
.leftJoinAndSelect('workspaces.docs', 'docs', this._onDoc(scope))
|
.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
|
// 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
|
// properties set, as documented in app/common/UserAPI. The return type of this function
|
||||||
// should reflect that.
|
// should reflect that.
|
||||||
public async getDocImpl(key: DocAuthKey): Promise<Document> {
|
public async getDocImpl(key: DocAuthKey, transaction?: EntityManager): Promise<Document> {
|
||||||
const {userId} = key;
|
const {userId} = key;
|
||||||
// Doc permissions of forks are based on the "trunk" document, so make sure
|
// 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
|
// 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
|
// 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
|
// everything with showAll flag, and let the getDoc() wrapper deal with the remaining
|
||||||
// work.
|
// work.
|
||||||
let qb = this._doc({...key, showAll: true})
|
let qb = this._doc({...key, showAll: true}, {manager: transaction})
|
||||||
.leftJoinAndSelect('orgs.owner', 'org_users');
|
.leftJoinAndSelect('orgs.owner', 'org_users');
|
||||||
|
if (userId !== this.getAnonymousUserId()) {
|
||||||
|
qb = this._addForks(userId, qb);
|
||||||
|
}
|
||||||
qb = this._addIsSupportWorkspace(userId, qb, 'orgs', 'workspaces');
|
qb = this._addIsSupportWorkspace(userId, qb, 'orgs', 'workspaces');
|
||||||
qb = this._addFeatures(qb); // add features to determine whether we've gone readonly
|
qb = this._addFeatures(qb); // add features to determine whether we've gone readonly
|
||||||
const docs = this.unwrapQueryResult<Document[]>(await this._verifyAclPermissions(qb));
|
const docs = this.unwrapQueryResult<Document[]>(await this._verifyAclPermissions(qb));
|
||||||
@ -1214,7 +1221,6 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
if (forkId || snapshotId) {
|
if (forkId || snapshotId) {
|
||||||
doc.trunkId = doc.id;
|
doc.trunkId = doc.id;
|
||||||
doc.trunkUrlId = doc.urlId;
|
|
||||||
|
|
||||||
// Fix up our reply to be correct for the fork, rather than the trunk.
|
// Fix up our reply to be correct for the fork, rather than the trunk.
|
||||||
// The "id" and "urlId" fields need updating.
|
// The "id" and "urlId" fields need updating.
|
||||||
@ -1227,17 +1233,20 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
doc.trunkAccess = doc.access;
|
doc.trunkAccess = doc.access;
|
||||||
|
|
||||||
// Update access for fork.
|
// 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;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calls getDocImpl() and returns the Document from that, caching a fresh DocAuthResult along
|
// 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.
|
// 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 scope = "params" in reqOrScope ? getScope(reqOrScope) : reqOrScope;
|
||||||
const key = getDocAuthKeyFromScope(scope);
|
const key = getDocAuthKeyFromScope(scope);
|
||||||
const promise = this.getDocImpl(key);
|
const promise = this.getDocImpl(key, transaction);
|
||||||
await mapSetOrClear(this._docAuthCache, stringifyDocAuthKey(key), makeDocAuthResult(promise));
|
await mapSetOrClear(this._docAuthCache, stringifyDocAuthKey(key), makeDocAuthResult(promise));
|
||||||
const doc = await promise;
|
const doc = await promise;
|
||||||
// Filter the result for removed / non-removed documents.
|
// Filter the result for removed / non-removed documents.
|
||||||
@ -1249,8 +1258,12 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRawDocById(docId: string) {
|
public async getRawDocById(docId: string, transaction?: EntityManager) {
|
||||||
return await this.getDoc({urlId: docId, userId: this.getPreviewerUserId(), showAll: true});
|
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
|
// 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.
|
// query result with status 200 on success.
|
||||||
// NOTE: This does not update the updateAt date indicating the last modified time of the doc.
|
// NOTE: This does not update the updateAt date indicating the last modified time of the doc.
|
||||||
// We may want to make it do so.
|
// We may want to make it do so.
|
||||||
public async updateDocument(scope: DocScope,
|
public async updateDocument(
|
||||||
props: Partial<DocumentProperties>): Promise<QueryResult<number>> {
|
scope: DocScope,
|
||||||
|
props: Partial<DocumentProperties>,
|
||||||
|
transaction?: EntityManager
|
||||||
|
): Promise<QueryResult<number>> {
|
||||||
const markPermissions = Permissions.SCHEMA_EDIT;
|
const markPermissions = Permissions.SCHEMA_EDIT;
|
||||||
return await this._connection.transaction(async manager => {
|
return await this._runInTransaction(transaction, async (manager) => {
|
||||||
const docQuery = this._doc(scope, {
|
const {forkId} = parseUrlId(scope.urlId);
|
||||||
manager,
|
let query: SelectQueryBuilder<Document>;
|
||||||
markPermissions
|
if (forkId) {
|
||||||
});
|
query = this._fork(scope, {
|
||||||
|
manager,
|
||||||
const queryResult = await verifyIsPermitted(docQuery);
|
});
|
||||||
|
} else {
|
||||||
|
query = this._doc(scope, {
|
||||||
|
manager,
|
||||||
|
markPermissions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const queryResult = await verifyIsPermitted(query);
|
||||||
if (queryResult.status !== 200) {
|
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;
|
return queryResult;
|
||||||
}
|
}
|
||||||
// Update the name and save.
|
// Update the name and save.
|
||||||
const doc: Document = queryResult.data;
|
const doc: Document = queryResult.data;
|
||||||
doc.checkProperties(props);
|
doc.checkProperties(props);
|
||||||
doc.updateFromProperties(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
|
// 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
|
// doesn't try to save it. It isn't safe to do that because it was filtered by
|
||||||
// a where clause.
|
// a where clause.
|
||||||
@ -1930,28 +1957,44 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
// status 200 on success.
|
// status 200 on success.
|
||||||
public async deleteDocument(scope: DocScope): Promise<QueryResult<number>> {
|
public async deleteDocument(scope: DocScope): Promise<QueryResult<number>> {
|
||||||
return await this._connection.transaction(async manager => {
|
return await this._connection.transaction(async manager => {
|
||||||
const docQuery = this._doc(scope, {
|
const {forkId} = parseUrlId(scope.urlId);
|
||||||
manager,
|
if (forkId) {
|
||||||
markPermissions: Permissions.REMOVE | Permissions.SCHEMA_EDIT,
|
const forkQuery = this._fork(scope, {
|
||||||
allowSpecialPermit: true
|
manager,
|
||||||
})
|
allowSpecialPermit: true,
|
||||||
// Join the docs's ACLs and groups so we can remove them.
|
});
|
||||||
// Join the workspace and org to get their ids.
|
const queryResult = await verifyIsPermitted(forkQuery);
|
||||||
.leftJoinAndSelect('docs.aclRules', 'acl_rules')
|
if (queryResult.status !== 200) {
|
||||||
.leftJoinAndSelect('acl_rules.group', 'groups');
|
// If the query for the fork failed, return the failure result.
|
||||||
const queryResult = await verifyIsPermitted(docQuery);
|
return queryResult;
|
||||||
if (queryResult.status !== 200) {
|
}
|
||||||
// If the query for the workspace failed, return the failure result.
|
const fork: Document = queryResult.data;
|
||||||
return queryResult;
|
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);
|
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
|
// 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.
|
// 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.
|
// 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.
|
// have been flattened.
|
||||||
if (forkId || snapshotId) {
|
if (forkId || snapshotId) {
|
||||||
for (const user of users) {
|
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)
|
let query = this.org(scope, org, options)
|
||||||
.leftJoinAndSelect('orgs.workspaces', 'workspaces')
|
.leftJoinAndSelect('orgs.workspaces', 'workspaces')
|
||||||
.leftJoinAndSelect('workspaces.docs', 'docs', this._onDoc(scope))
|
.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('orgs.billingAccount', 'account')
|
||||||
.leftJoin('account.product', 'product')
|
.leftJoin('account.product', 'product')
|
||||||
.addSelect('product.features')
|
.addSelect('product.features')
|
||||||
@ -2881,13 +2897,17 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
// order the support org (aka Samples/Examples) after other ones.
|
// order the support org (aka Samples/Examples) after other ones.
|
||||||
.orderBy('coalesce(orgs.owner_id = :supportId, false)')
|
.orderBy('coalesce(orgs.owner_id = :supportId, false)')
|
||||||
.setParameter('supportId', supportId)
|
.setParameter('supportId', supportId)
|
||||||
.addOrderBy('(orgs.owner_id = :userId)', 'DESC')
|
|
||||||
.setParameter('userId', userId)
|
.setParameter('userId', userId)
|
||||||
|
.addOrderBy('(orgs.owner_id = :userId)', 'DESC')
|
||||||
// For consistency of results, particularly in tests, order workspaces by name.
|
// For consistency of results, particularly in tests, order workspaces by name.
|
||||||
.addOrderBy('workspaces.name')
|
.addOrderBy('workspaces.name')
|
||||||
.addOrderBy('docs.created_at')
|
.addOrderBy('docs.created_at')
|
||||||
.leftJoinAndSelect('orgs.owner', 'org_users');
|
.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 merged org, we need to take some special steps.
|
||||||
if (this.isMergedOrg(org)) {
|
if (this.isMergedOrg(org)) {
|
||||||
// Add information about owners of personal orgs.
|
// 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);
|
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.
|
* 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
|
* 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
|
* have evolved (the main constraint is that currently forks have no access info of
|
||||||
* their own in the db).
|
* 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 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 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.
|
* - 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}) {
|
res: {access: roles.Role|null}) {
|
||||||
// Forks without a user id are editable by anyone with view access to the trunk.
|
if (doc.type === 'tutorial') {
|
||||||
if (ids.forkUserId === undefined && roles.canView(res.access)) { res.access = 'owners'; }
|
if (ids.userId === this.getPreviewerUserId()) {
|
||||||
if (ids.forkUserId !== undefined) {
|
res.access = 'viewers';
|
||||||
// A fork user id is known, so only that user should get to edit the fork.
|
} else if (ids.forkUserId && ids.forkUserId === ids.userId) {
|
||||||
if (ids.userId === ids.forkUserId) {
|
res.access = 'owners';
|
||||||
if (roles.canView(res.access)) { res.access = 'owners'; }
|
|
||||||
} else {
|
} else {
|
||||||
// reduce to viewer if not already viewer
|
res.access = null;
|
||||||
res.access = roles.getWeakestRole('viewers', res.access);
|
}
|
||||||
|
} 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;
|
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) {
|
private _workspaces(manager?: EntityManager) {
|
||||||
return (manager || this._connection).createQueryBuilder()
|
return (manager || this._connection).createQueryBuilder()
|
||||||
.select('workspaces')
|
.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
|
* 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.
|
* 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),
|
// 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.
|
// and to get fresh (uncached) access info.
|
||||||
doc = await dbManager.getDoc({userId, org: mreq.org, urlId});
|
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 slugMismatch = (req.params.slug || null) !== (slug || null);
|
||||||
const preferredUrlId = doc.urlId || doc.id;
|
const preferredUrlId = doc.urlId || doc.id;
|
||||||
if (urlId !== preferredUrlId || slugMismatch) {
|
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
|
// 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.
|
// that they log in. This is the same check made in redirectToLogin() middleware.
|
||||||
const result = await dbManager.getOrg({userId: getUserId(mreq)}, mreq.org || null);
|
const result = await dbManager.getOrg({userId: getUserId(mreq)}, mreq.org || null);
|
||||||
if (result.status !== 200) {
|
if (result.status !== 200 || doc?.type === 'tutorial') {
|
||||||
// Anonymous user does not have any access to this org, or to this doc.
|
// Anonymous user does not have any access to this org, doc, or tutorial.
|
||||||
// Redirect to log in.
|
// Redirect to log in.
|
||||||
return forceLogin(req, res, next);
|
return forceLogin(req, res, next);
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import {SortFunc} from 'app/common/SortFunc';
|
|||||||
import {Sort} from 'app/common/SortSpec';
|
import {Sort} from 'app/common/SortSpec';
|
||||||
import {MetaRowRecord} from 'app/common/TableData';
|
import {MetaRowRecord} from 'app/common/TableData';
|
||||||
import {DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
|
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 * as Types from "app/plugin/DocApiTypes";
|
||||||
import DocApiTypesTI from "app/plugin/DocApiTypes-ti";
|
import DocApiTypesTI from "app/plugin/DocApiTypes-ti";
|
||||||
import GristDataTI from 'app/plugin/GristData-ti';
|
import GristDataTI from 'app/plugin/GristData-ti';
|
||||||
@ -784,6 +784,27 @@ export class DocWorkerApi {
|
|||||||
'Content-Type': 'application/json',
|
'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) {
|
if (req.body.snapshotId) {
|
||||||
options.snapshotId = String(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)));
|
await Promise.all(docsToDelete.map(docName => this._docManager.deleteDoc(null, docName, true)));
|
||||||
// Permanently delete from database.
|
// Permanently delete from database.
|
||||||
let query: QueryResult<number>;
|
const query = await this._dbManager.deleteDocument(scope);
|
||||||
if (forkId) {
|
|
||||||
query = await this._dbManager.deleteFork({...scope, urlId: forkId});
|
|
||||||
} else {
|
|
||||||
query = await this._dbManager.deleteDocument(scope);
|
|
||||||
}
|
|
||||||
this._dbManager.checkQueryResult(query);
|
this._dbManager.checkQueryResult(query);
|
||||||
await sendReply(req, res, query);
|
await sendReply(req, res, query);
|
||||||
} else {
|
} else {
|
||||||
|
@ -46,6 +46,7 @@
|
|||||||
"@types/chai-as-promised": "7.1.0",
|
"@types/chai-as-promised": "7.1.0",
|
||||||
"@types/content-disposition": "0.5.2",
|
"@types/content-disposition": "0.5.2",
|
||||||
"@types/diff-match-patch": "1.0.32",
|
"@types/diff-match-patch": "1.0.32",
|
||||||
|
"@types/dompurify": "2.4.0",
|
||||||
"@types/double-ended-queue": "2.1.0",
|
"@types/double-ended-queue": "2.1.0",
|
||||||
"@types/express": "4.16.0",
|
"@types/express": "4.16.0",
|
||||||
"@types/form-data": "2.2.1",
|
"@types/form-data": "2.2.1",
|
||||||
@ -59,6 +60,7 @@
|
|||||||
"@types/jsonwebtoken": "7.2.8",
|
"@types/jsonwebtoken": "7.2.8",
|
||||||
"@types/lodash": "4.14.117",
|
"@types/lodash": "4.14.117",
|
||||||
"@types/lru-cache": "5.1.1",
|
"@types/lru-cache": "5.1.1",
|
||||||
|
"@types/marked": "4.0.8",
|
||||||
"@types/mime-types": "2.1.0",
|
"@types/mime-types": "2.1.0",
|
||||||
"@types/minio": "7.0.15",
|
"@types/minio": "7.0.15",
|
||||||
"@types/mocha": "5.2.5",
|
"@types/mocha": "5.2.5",
|
||||||
@ -130,6 +132,7 @@
|
|||||||
"cookie-parser": "1.4.3",
|
"cookie-parser": "1.4.3",
|
||||||
"csv": "4.0.0",
|
"csv": "4.0.0",
|
||||||
"diff-match-patch": "1.0.5",
|
"diff-match-patch": "1.0.5",
|
||||||
|
"dompurify": "3.0.0",
|
||||||
"double-ended-queue": "2.1.0-0",
|
"double-ended-queue": "2.1.0-0",
|
||||||
"exceljs": "4.2.1",
|
"exceljs": "4.2.1",
|
||||||
"express": "4.16.4",
|
"express": "4.16.4",
|
||||||
@ -152,6 +155,7 @@
|
|||||||
"knockout": "3.5.0",
|
"knockout": "3.5.0",
|
||||||
"locale-currency": "0.0.2",
|
"locale-currency": "0.0.2",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
|
"marked": "4.2.12",
|
||||||
"minio": "7.0.32",
|
"minio": "7.0.32",
|
||||||
"moment": "2.29.4",
|
"moment": "2.29.4",
|
||||||
"moment-timezone": "0.5.35",
|
"moment-timezone": "0.5.35",
|
||||||
|
@ -80,8 +80,10 @@
|
|||||||
--icon-Lock: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIj48cGF0aCBkPSJNMTIgNkwxMCA2IDEwIDRDMTAgMi44OTU0MzA1IDkuMTA0NTY5NSAyIDggMiA2Ljg5NTQzMDUgMiA2IDIuODk1NDMwNSA2IDRMNiA2IDQgNiA0IDRDNCAxLjc5MDg2MSA1Ljc5MDg2MSAwIDggMCAxMC4yMDkxMzkgMCAxMiAxLjc5MDg2MSAxMiA0TDEyIDZ6TTE0IDdMMiA3QzEuNDQ3NzE1MjUgNyAxIDcuNDQ3NzE1MjUgMSA4TDEgMTVDMSAxNS41NTIyODQ3IDEuNDQ3NzE1MjUgMTYgMiAxNkwxNCAxNkMxNC41NTIyODQ3IDE2IDE1IDE1LjU1MjI4NDcgMTUgMTVMMTUgOEMxNSA3LjQ0NzcxNTI1IDE0LjU1MjI4NDcgNyAxNCA3ek04IDEzQzYuODk1NDMwNSAxMyA2IDEyLjEwNDU2OTUgNiAxMSA2IDkuODk1NDMwNSA2Ljg5NTQzMDUgOSA4IDkgOS4xMDQ1Njk1IDkgMTAgOS44OTU0MzA1IDEwIDExIDEwIDEyLjEwNDU2OTUgOS4xMDQ1Njk1IDEzIDggMTN6Ii8+PC9nPjwvc3ZnPg==');
|
--icon-Lock: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIj48cGF0aCBkPSJNMTIgNkwxMCA2IDEwIDRDMTAgMi44OTU0MzA1IDkuMTA0NTY5NSAyIDggMiA2Ljg5NTQzMDUgMiA2IDIuODk1NDMwNSA2IDRMNiA2IDQgNiA0IDRDNCAxLjc5MDg2MSA1Ljc5MDg2MSAwIDggMCAxMC4yMDkxMzkgMCAxMiAxLjc5MDg2MSAxMiA0TDEyIDZ6TTE0IDdMMiA3QzEuNDQ3NzE1MjUgNyAxIDcuNDQ3NzE1MjUgMSA4TDEgMTVDMSAxNS41NTIyODQ3IDEuNDQ3NzE1MjUgMTYgMiAxNkwxNCAxNkMxNC41NTIyODQ3IDE2IDE1IDE1LjU1MjI4NDcgMTUgMTVMMTUgOEMxNSA3LjQ0NzcxNTI1IDE0LjU1MjI4NDcgNyAxNCA3ek04IDEzQzYuODk1NDMwNSAxMyA2IDEyLjEwNDU2OTUgNiAxMSA2IDkuODk1NDMwNSA2Ljg5NTQzMDUgOSA4IDkgOS4xMDQ1Njk1IDkgMTAgOS44OTU0MzA1IDEwIDExIDEwIDEyLjEwNDU2OTUgOS4xMDQ1Njk1IDEzIDggMTN6Ii8+PC9nPjwvc3ZnPg==');
|
||||||
--icon-Log: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSw4LjUgTDYuNSw4LjUgQzYuMjIzODU3NjMsOC41IDYsOC4yNzYxNDIzNyA2LDggQzYsNy43MjM4NTc2MyA2LjIyMzg1NzYzLDcuNSA2LjUsNy41IEw3LjUsNy41IEw3LjUsMC41IEM3LjUsMC4yMjM4NTc2MjUgNy43MjM4NTc2MywwIDgsMCBDOC4yNzYxNDIzNywwIDguNSwwLjIyMzg1NzYyNSA4LjUsMC41IEw4LjUsMyBMOS41LDMgQzkuNzc2MTQyMzcsMyAxMCwzLjIyMzg1NzYzIDEwLDMuNSBDMTAsMy43NzYxNDIzNyA5Ljc3NjE0MjM3LDQgOS41LDQgTDguNSw0IEw4LjUsMTIgTDkuNSwxMiBDOS43NzYxNDIzNywxMiAxMCwxMi4yMjM4NTc2IDEwLDEyLjUgQzEwLDEyLjc3NjE0MjQgOS43NzYxNDIzNywxMyA5LjUsMTMgTDguNSwxMyBMOC41LDE1LjUgQzguNSwxNS43NzYxNDI0IDguMjc2MTQyMzcsMTYgOCwxNiBDNy43MjM4NTc2MywxNiA3LjUsMTUuNzc2MTQyNCA3LjUsMTUuNSBMNy41LDguNSBaIE0xMiwyIEwxMiw1IEwxNSw1IEwxNSwyIEwxMiwyIFogTTExLjUsMSBMMTUuNSwxIEMxNS43NzYxNDI0LDEgMTYsMS4yMjM4NTc2MyAxNiwxLjUgTDE2LDUuNSBDMTYsNS43NzYxNDIzNyAxNS43NzYxNDI0LDYgMTUuNSw2IEwxMS41LDYgQzExLjIyMzg1NzYsNiAxMSw1Ljc3NjE0MjM3IDExLDUuNSBMMTEsMS41IEMxMSwxLjIyMzg1NzYzIDExLjIyMzg1NzYsMSAxMS41LDEgWiBNMTIsMTEgTDEyLDE0IEwxNSwxNCBMMTUsMTEgTDEyLDExIFogTTExLjUsMTAgTDE1LjUsMTAgQzE1Ljc3NjE0MjQsMTAgMTYsMTAuMjIzODU3NiAxNiwxMC41IEwxNiwxNC41IEMxNiwxNC43NzYxNDI0IDE1Ljc3NjE0MjQsMTUgMTUuNSwxNSBMMTEuNSwxNSBDMTEuMjIzODU3NiwxNSAxMSwxNC43NzYxNDI0IDExLDE0LjUgTDExLDEwLjUgQzExLDEwLjIyMzg1NzYgMTEuMjIzODU3NiwxMCAxMS41LDEwIFogTTQsOS41IEw0LDYuNSBMMSw2LjUgTDEsOS41IEw0LDkuNSBaIE00LjUsMTAuNSBMMC41LDEwLjUgQzAuMjIzODU3NjI1LDEwLjUgMCwxMC4yNzYxNDI0IDAsMTAgTDguODgxNzg0MmUtMTYsNiBDOC44ODE3ODQyZS0xNiw1LjcyMzg1NzYzIDAuMjIzODU3NjI1LDUuNSAwLjUsNS41IEw0LjUsNS41IEM0Ljc3NjE0MjM3LDUuNSA1LDUuNzIzODU3NjMgNSw2IEw1LDEwIEM1LDEwLjI3NjE0MjQgNC43NzYxNDIzNywxMC41IDQuNSwxMC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
--icon-Log: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSw4LjUgTDYuNSw4LjUgQzYuMjIzODU3NjMsOC41IDYsOC4yNzYxNDIzNyA2LDggQzYsNy43MjM4NTc2MyA2LjIyMzg1NzYzLDcuNSA2LjUsNy41IEw3LjUsNy41IEw3LjUsMC41IEM3LjUsMC4yMjM4NTc2MjUgNy43MjM4NTc2MywwIDgsMCBDOC4yNzYxNDIzNywwIDguNSwwLjIyMzg1NzYyNSA4LjUsMC41IEw4LjUsMyBMOS41LDMgQzkuNzc2MTQyMzcsMyAxMCwzLjIyMzg1NzYzIDEwLDMuNSBDMTAsMy43NzYxNDIzNyA5Ljc3NjE0MjM3LDQgOS41LDQgTDguNSw0IEw4LjUsMTIgTDkuNSwxMiBDOS43NzYxNDIzNywxMiAxMCwxMi4yMjM4NTc2IDEwLDEyLjUgQzEwLDEyLjc3NjE0MjQgOS43NzYxNDIzNywxMyA5LjUsMTMgTDguNSwxMyBMOC41LDE1LjUgQzguNSwxNS43NzYxNDI0IDguMjc2MTQyMzcsMTYgOCwxNiBDNy43MjM4NTc2MywxNiA3LjUsMTUuNzc2MTQyNCA3LjUsMTUuNSBMNy41LDguNSBaIE0xMiwyIEwxMiw1IEwxNSw1IEwxNSwyIEwxMiwyIFogTTExLjUsMSBMMTUuNSwxIEMxNS43NzYxNDI0LDEgMTYsMS4yMjM4NTc2MyAxNiwxLjUgTDE2LDUuNSBDMTYsNS43NzYxNDIzNyAxNS43NzYxNDI0LDYgMTUuNSw2IEwxMS41LDYgQzExLjIyMzg1NzYsNiAxMSw1Ljc3NjE0MjM3IDExLDUuNSBMMTEsMS41IEMxMSwxLjIyMzg1NzYzIDExLjIyMzg1NzYsMSAxMS41LDEgWiBNMTIsMTEgTDEyLDE0IEwxNSwxNCBMMTUsMTEgTDEyLDExIFogTTExLjUsMTAgTDE1LjUsMTAgQzE1Ljc3NjE0MjQsMTAgMTYsMTAuMjIzODU3NiAxNiwxMC41IEwxNiwxNC41IEMxNiwxNC43NzYxNDI0IDE1Ljc3NjE0MjQsMTUgMTUuNSwxNSBMMTEuNSwxNSBDMTEuMjIzODU3NiwxNSAxMSwxNC43NzYxNDI0IDExLDE0LjUgTDExLDEwLjUgQzExLDEwLjIyMzg1NzYgMTEuMjIzODU3NiwxMCAxMS41LDEwIFogTTQsOS41IEw0LDYuNSBMMSw2LjUgTDEsOS41IEw0LDkuNSBaIE00LjUsMTAuNSBMMC41LDEwLjUgQzAuMjIzODU3NjI1LDEwLjUgMCwxMC4yNzYxNDI0IDAsMTAgTDguODgxNzg0MmUtMTYsNiBDOC44ODE3ODQyZS0xNiw1LjcyMzg1NzYzIDAuMjIzODU3NjI1LDUuNSAwLjUsNS41IEw0LjUsNS41IEM0Ljc3NjE0MjM3LDUuNSA1LDUuNzIzODU3NjMgNSw2IEw1LDEwIEM1LDEwLjI3NjE0MjQgNC43NzYxNDIzNywxMC41IDQuNSwxMC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||||
--icon-Mail: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjUsMTMgQzE0Ljc3NTg1NzYsMTMgMTUsMTIuNzc1ODU3NiAxNSwxMi41IEwxNSwxLjUgQzE1LDEuMjI0MTQyMzcgMTQuNzc1ODU3NiwxIDE0LjUsMSBMMS41LDEgQzEuMjI0MTQyMzcsMSAxLDEuMjI0MTQyMzcgMSwxLjUgTDEsMTIuNSBDMSwxMi43NzU4NTc2IDEuMjI0MTQyMzcsMTMgMS41LDEzIEwxNC41LDEzIFogTTE0LjUsMTQgTDEuNSwxNCBDMC42NzE4NTc2MjUsMTQgMCwxMy4zMjgxNDI0IDAsMTIuNSBMMCwxLjUgQzAsMC42NzE4NTc2MjUgMC42NzE4NTc2MjUsOC44ODE3ODQyZS0xNiAxLjUsOC44ODE3ODQyZS0xNiBMMTQuNSw4Ljg4MTc4NDJlLTE2IEMxNS4zMjgxNDI0LDguODgxNzg0MmUtMTYgMTYsMC42NzE4NTc2MjUgMTYsMS41IEwxNiwxMi41IEMxNiwxMy4zMjgxNDI0IDE1LjMyODE0MjQsMTQgMTQuNSwxNCBaIE0xMy4xODMzODExLDMuMTEzMDIxMzUgQzEzLjM5NzEwMzUsMi45MzgxNTc1NiAxMy43MTIxMTQ5LDIuOTY5NjU4NyAxMy44ODY5Nzg2LDMuMTgzMzgxMSBDMTQuMDYxODQyNCwzLjM5NzEwMzUxIDE0LjAzMDM0MTMsMy43MTIxMTQ4NiAxMy44MTY2MTg5LDMuODg2OTc4NjUgTDguMzE2NjE4OSw4LjM4Njk3ODY1IEM4LjEzMjQzNTk1LDguNTM3NjczNzggNy44Njc1NjQwNSw4LjUzNzY3Mzc4IDcuNjgzMzgxMSw4LjM4Njk3ODY1IEwyLjE4MzM4MTEsMy44ODY5Nzg2NSBDMS45Njk2NTg3LDMuNzEyMTE0ODYgMS45MzgxNTc1NiwzLjM5NzEwMzUxIDIuMTEzMDIxMzUsMy4xODMzODExIEMyLjI4Nzg4NTE0LDIuOTY5NjU4NyAyLjYwMjg5NjQ5LDIuOTM4MTU3NTYgMi44MTY2MTg5LDMuMTEzMDIxMzUgTDgsNy4zNTM5Njk1MyBMMTMuMTgzMzgxMSwzLjExMzAyMTM1IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
--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-Memo: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzE4NTFfMTYzMjIpIiBzdHJva2U9IiMwMDkwNTgiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTQuNS41SDEuNUMuOTQ4LjUuNS45NDguNSAxLjVWMTEuNUMuNSAxMi4wNTIuOTQ4IDEyLjUgMS41IDEyLjVINS41TDggMTUuNSAxMC41IDEyLjVIMTQuNUMxNS4wNTIgMTIuNSAxNS41IDEyLjA1MiAxNS41IDExLjVWMS41QzE1LjUuOTQ4IDE1LjA1Mi41IDE0LjUuNXpNMy41IDQuNUgxMi41TTMuNSA4LjVIMTIuNSIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwXzE4NTFfMTYzMjIiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=');
|
||||||
--icon-Message: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE1LjUgN0MxNS41IDMuNDEgMTIuMTQyIDAuNSA4IDAuNUMzLjg1OCAwLjUgMC41IDMuNDEgMC41IDdDMC41IDEwLjU5IDMuODU4IDEzLjUgOCAxMy41QzguNTI1IDEzLjUgOS4wMzcgMTMuNDUyIDkuNTMyIDEzLjM2M0wxMy41IDE1LjVWMTEuNDA5QzE0LjczOCAxMC4yNSAxNS41IDguNzA0IDE1LjUgN1oiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
|
--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-Minus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4=');
|
||||||
--icon-MobileChat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUgOC41VjE0LjVDMTEuNSAxNC43NjUyIDExLjM5NDYgMTUuMDE5NiAxMS4yMDcxIDE1LjIwNzFDMTEuMDE5NiAxNS4zOTQ2IDEwLjc2NTIgMTUuNSAxMC41IDE1LjVIMi41QzIuMjM0NzggMTUuNSAxLjk4MDQzIDE1LjM5NDYgMS43OTI4OSAxNS4yMDcxQzEuNjA1MzYgMTUuMDE5NiAxLjUgMTQuNzY1MiAxLjUgMTQuNVYyLjVDMS41IDIuMjM0NzggMS42MDUzNiAxLjk4MDQzIDEuNzkyODkgMS43OTI4OUMxLjk4MDQzIDEuNjA1MzYgMi4yMzQ3OCAxLjUgMi41IDEuNUg0LjUiIHN0cm9rZT0iIzI2MjYzMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE0LjUgNi41SDkuNUw2LjUgOC41VjEuNUM2LjUgMS4yMzQ3OCA2LjYwNTM2IDAuOTgwNDMgNi43OTI4OSAwLjc5Mjg5M0M2Ljk4MDQzIDAuNjA1MzU3IDcuMjM0NzggMC41IDcuNSAwLjVIMTQuNUMxNC43NjUyIDAuNSAxNS4wMTk2IDAuNjA1MzU3IDE1LjIwNzEgMC43OTI4OTNDMTUuMzk0NiAwLjk4MDQzIDE1LjUgMS4yMzQ3OCAxNS41IDEuNVY1LjVDMTUuNSA1Ljc2NTIyIDE1LjM5NDYgNi4wMTk1NyAxNS4yMDcxIDYuMjA3MTFDMTUuMDE5NiA2LjM5NDY0IDE0Ljc2NTIgNi41IDE0LjUgNi41WiIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
|
--icon-MobileChat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUgOC41VjE0LjVDMTEuNSAxNC43NjUyIDExLjM5NDYgMTUuMDE5NiAxMS4yMDcxIDE1LjIwNzFDMTEuMDE5NiAxNS4zOTQ2IDEwLjc2NTIgMTUuNSAxMC41IDE1LjVIMi41QzIuMjM0NzggMTUuNSAxLjk4MDQzIDE1LjM5NDYgMS43OTI4OSAxNS4yMDcxQzEuNjA1MzYgMTUuMDE5NiAxLjUgMTQuNzY1MiAxLjUgMTQuNVYyLjVDMS41IDIuMjM0NzggMS42MDUzNiAxLjk4MDQzIDEuNzkyODkgMS43OTI4OUMxLjk4MDQzIDEuNjA1MzYgMi4yMzQ3OCAxLjUgMi41IDEuNUg0LjUiIHN0cm9rZT0iIzI2MjYzMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE0LjUgNi41SDkuNUw2LjUgOC41VjEuNUM2LjUgMS4yMzQ3OCA2LjYwNTM2IDAuOTgwNDMgNi43OTI4OSAwLjc5Mjg5M0M2Ljk4MDQzIDAuNjA1MzU3IDcuMjM0NzggMC41IDcuNSAwLjVIMTQuNUMxNC43NjUyIDAuNSAxNS4wMTk2IDAuNjA1MzU3IDE1LjIwNzEgMC43OTI4OTNDMTUuMzk0NiAwLjk4MDQzIDE1LjUgMS4yMzQ3OCAxNS41IDEuNVY1LjVDMTUuNSA1Ljc2NTIyIDE1LjM5NDYgNi4wMTk1NyAxNS4yMDcxIDYuMjA3MTFDMTUuMDE5NiA2LjM5NDY0IDE0Ljc2NTIgNi41IDE0LjUgNi41WiIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
|
||||||
--icon-MobileChat2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTggMEgxNUMxNS4yNjUyIDAgMTUuNTE5NiAwLjEwNTM1NyAxNS43MDcxIDAuMjkyODkzQzE1Ljg5NDYgMC40ODA0MyAxNiAwLjczNDc4NCAxNiAxVjVDMTYgNS4yNjUyMiAxNS44OTQ2IDUuNTE5NTcgMTUuNzA3MSA1LjcwNzExQzE1LjUxOTYgNS44OTQ2NCAxNS4yNjUyIDYgMTUgNkgxMEw3IDhWMUM3IDAuNzM0Nzg0IDcuMTA1MzYgMC40ODA0MyA3LjI5Mjg5IDAuMjkyODkzQzcuNDgwNDMgMC4xMDUzNTcgNy43MzQ3OCAwIDggMFYwWiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik0xMCA3VjEzSDNWNEg2VjFIM0MyLjQ2OTU3IDEgMS45NjA4NiAxLjIxMDcxIDEuNTg1NzkgMS41ODU3OUMxLjIxMDcxIDEuOTYwODYgMSAyLjQ2OTU3IDEgM1YxNEMxIDE0LjUzMDQgMS4yMTA3MSAxNS4wMzkxIDEuNTg1NzkgMTUuNDE0MkMxLjk2MDg2IDE1Ljc4OTMgMi40Njk1NyAxNiAzIDE2SDEwQzEwLjUzMDQgMTYgMTEuMDM5MSAxNS43ODkzIDExLjQxNDIgMTUuNDE0MkMxMS43ODkzIDE1LjAzOTEgMTIgMTQuNTMwNCAxMiAxNFY3SDEwWiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg==');
|
--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"
|
resolved "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.32.tgz"
|
||||||
integrity sha512-bPYT5ECFiblzsVzyURaNhljBH2Gh1t9LowgUwciMrNAhFewLkHT2H0Mto07Y4/3KCOGZHRQll3CTtQZ0X11D/A==
|
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":
|
"@types/double-ended-queue@2.1.0":
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.npmjs.org/@types/double-ended-queue/-/double-ended-queue-2.1.0.tgz"
|
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"
|
resolved "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.1.tgz"
|
||||||
integrity sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==
|
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":
|
"@types/mime-types@2.1.0":
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.0.tgz"
|
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"
|
resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz"
|
||||||
integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==
|
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@*":
|
"@types/underscore@*":
|
||||||
version "1.11.0"
|
version "1.11.0"
|
||||||
resolved "https://registry.npmjs.org/@types/underscore/-/underscore-1.11.0.tgz"
|
resolved "https://registry.npmjs.org/@types/underscore/-/underscore-1.11.0.tgz"
|
||||||
@ -3008,6 +3025,11 @@ domexception@^2.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
webidl-conversions "^5.0.0"
|
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:
|
dot-prop@^5.2.0:
|
||||||
version "5.2.0"
|
version "5.2.0"
|
||||||
resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz"
|
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"
|
socks-proxy-agent "^6.0.0"
|
||||||
ssri "^8.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:
|
md5.js@^1.3.4:
|
||||||
version "1.3.5"
|
version "1.3.5"
|
||||||
resolved "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz"
|
resolved "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz"
|
||||||
|
Loading…
Reference in New Issue
Block a user