mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +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:
@@ -19,13 +19,16 @@ import * as koArray from 'app/client/lib/koArray';
|
||||
import * as koUtil from 'app/client/lib/koUtil';
|
||||
import DataTableModel from 'app/client/models/DataTableModel';
|
||||
import {DocData} from 'app/client/models/DocData';
|
||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import MetaRowModel from 'app/client/models/MetaRowModel';
|
||||
import MetaTableModel from 'app/client/models/MetaTableModel';
|
||||
import * as rowset from 'app/client/models/rowset';
|
||||
import {TableData} from 'app/client/models/TableData';
|
||||
import {isHiddenTable, isSummaryTable} from 'app/common/isHiddenTable';
|
||||
import {RowFilterFunc} from 'app/common/RowFilterFunc';
|
||||
import {schema, SchemaTypes} from 'app/common/schema';
|
||||
import {UIRowId} from 'app/common/UIRowId';
|
||||
|
||||
import {ACLRuleRec, createACLRuleRec} from 'app/client/models/entities/ACLRuleRec';
|
||||
import {ColumnRec, createColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
@@ -41,6 +44,7 @@ import {createViewSectionRec, ViewSectionRec} from 'app/client/models/entities/V
|
||||
import {CellRec, createCellRec} from 'app/client/models/entities/CellRec';
|
||||
import {RefListValue} from 'app/common/gristTypes';
|
||||
import {decodeObject} from 'app/plugin/objtypes';
|
||||
import { toKo } from 'grainjs';
|
||||
|
||||
// Re-export all the entity types available. The recommended usage is like this:
|
||||
// import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
|
||||
@@ -129,10 +133,12 @@ export class DocModel {
|
||||
|
||||
public docInfoRow: DocInfoRec;
|
||||
|
||||
public allTables: KoArray<TableRec>;
|
||||
public visibleTables: KoArray<TableRec>;
|
||||
public rawDataTables: KoArray<TableRec>;
|
||||
public rawSummaryTables: KoArray<TableRec>;
|
||||
|
||||
public allTableIds: KoArray<string>;
|
||||
public visibleTableIds: KoArray<string>;
|
||||
|
||||
// A mapping from tableId to DataTableModel for user-defined tables.
|
||||
@@ -151,14 +157,21 @@ export class DocModel {
|
||||
// Flag for tracking whether document is in formula-editing mode
|
||||
public editingFormula: ko.Observable<boolean> = ko.observable(false);
|
||||
|
||||
// If the doc has a docTour. Used also to enable the UI button to restart the tour.
|
||||
public readonly hasDocTour: ko.Computed<boolean>;
|
||||
|
||||
public readonly isTutorial: ko.Computed<boolean>;
|
||||
|
||||
// TODO This is a temporary solution until we expose creation of doc-tours to users. This flag
|
||||
// is initialized once on page load. If set, then the tour page (if any) will be visible.
|
||||
public showDocTourTable: boolean = (urlState().state.get().docPage === 'GristDocTour');
|
||||
|
||||
public showDocTutorialTable: boolean = !this._docPageModel.isTutorialFork.get();
|
||||
|
||||
// List of all the metadata tables.
|
||||
private _metaTables: Array<MetaTableModel<any>>;
|
||||
|
||||
constructor(public readonly docData: DocData) {
|
||||
constructor(public readonly docData: DocData, private readonly _docPageModel: DocPageModel) {
|
||||
// For all the metadata tables, load their data (and create the RowModels).
|
||||
for (const model of this._metaTables) {
|
||||
model.loadData();
|
||||
@@ -166,13 +179,20 @@ export class DocModel {
|
||||
|
||||
this.docInfoRow = this.docInfo.getRowModel(1);
|
||||
|
||||
// An observable array of all tables, sorted by tableId, with no exclusions.
|
||||
this.allTables = this._createAllTablesArray();
|
||||
|
||||
// An observable array of user-visible tables, sorted by tableId, excluding summary tables.
|
||||
// This is a publicly exposed member.
|
||||
this.visibleTables = createVisibleTablesArray(this.tables);
|
||||
this.visibleTables = this._createVisibleTablesArray();
|
||||
|
||||
// Observable arrays of raw data and summary tables, sorted by tableId.
|
||||
this.rawDataTables = createRawDataTablesArray(this.tables);
|
||||
this.rawSummaryTables = createRawSummaryTablesArray(this.tables);
|
||||
this.rawDataTables = this._createRawDataTablesArray();
|
||||
this.rawSummaryTables = this._createRawSummaryTablesArray();
|
||||
|
||||
// An observable array of all tableIds. A shortcut mapped from allTables.
|
||||
const allTableIds = ko.computed(() => this.allTables.all().map(t => t.tableId()));
|
||||
this.allTableIds = koArray.syncedKoArray(allTableIds);
|
||||
|
||||
// An observable array of user-visible tableIds. A shortcut mapped from visibleTables.
|
||||
const visibleTableIds = ko.computed(() => this.visibleTables.all().map(t => t.tableId()));
|
||||
@@ -206,6 +226,12 @@ export class DocModel {
|
||||
return pagesToShow.filter(p => !hide(p));
|
||||
});
|
||||
this.visibleDocPages = ko.computed(() => allPages.all().filter(p => !p.isHidden()));
|
||||
|
||||
this.hasDocTour = ko.computed(() => this.visibleTableIds.all().includes('GristDocTour'));
|
||||
|
||||
this.isTutorial = ko.computed(() =>
|
||||
toKo(ko, this._docPageModel.isTutorialFork)()
|
||||
&& this.allTableIds.all().includes('GristDocTutorial'));
|
||||
}
|
||||
|
||||
private _metaTableModel<TName extends keyof SchemaTypes, TRow extends IRowModel<TName>>(
|
||||
@@ -240,6 +266,42 @@ export class DocModel {
|
||||
delete this.dataTables[tid];
|
||||
this.dataTablesByRef.delete(tableMetaRow.getRowId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable array of all tables, sorted by tableId.
|
||||
*/
|
||||
private _createAllTablesArray(): KoArray<TableRec> {
|
||||
return createTablesArray(this.tables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable array of user tables, sorted by tableId, and excluding hidden/summary
|
||||
* tables.
|
||||
*/
|
||||
private _createVisibleTablesArray(): KoArray<TableRec> {
|
||||
return createTablesArray(this.tables, r =>
|
||||
!isHiddenTable(this.tables.tableData, r) &&
|
||||
(!isTutorialTable(this.tables.tableData, r) || this.showDocTutorialTable)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable array of raw data tables, sorted by tableId, and excluding summary
|
||||
* tables.
|
||||
*/
|
||||
private _createRawDataTablesArray(): KoArray<TableRec> {
|
||||
return createTablesArray(this.tables, r =>
|
||||
!isSummaryTable(this.tables.tableData, r) &&
|
||||
(!isTutorialTable(this.tables.tableData, r) || this.showDocTutorialTable)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable array of raw summary tables, sorted by tableId.
|
||||
*/
|
||||
private _createRawSummaryTablesArray(): KoArray<TableRec> {
|
||||
return createTablesArray(this.tables, r => isSummaryTable(this.tables.tableData, r));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -258,24 +320,9 @@ function createTablesArray(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable array of user tables, sorted by tableId, and excluding hidden/summary
|
||||
* tables.
|
||||
* Return whether a table (identified by the rowId of its metadata record) is
|
||||
* the special GristDocTutorial table.
|
||||
*/
|
||||
function createVisibleTablesArray(tablesModel: MetaTableModel<TableRec>): KoArray<TableRec> {
|
||||
return createTablesArray(tablesModel, r => !isHiddenTable(tablesModel.tableData, r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable array of raw data tables, sorted by tableId, and excluding summary
|
||||
* tables.
|
||||
*/
|
||||
function createRawDataTablesArray(tablesModel: MetaTableModel<TableRec>): KoArray<TableRec> {
|
||||
return createTablesArray(tablesModel, r => !isSummaryTable(tablesModel.tableData, r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable array of raw summary tables, sorted by tableId.
|
||||
*/
|
||||
function createRawSummaryTablesArray(tablesModel: MetaTableModel<TableRec>): KoArray<TableRec> {
|
||||
return createTablesArray(tablesModel, r => isSummaryTable(tablesModel.tableData, r));
|
||||
function isTutorialTable(tablesData: TableData, tableRef: UIRowId): boolean {
|
||||
return tablesData.getValue(tableRef, 'tableId') === 'GristDocTutorial';
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {delay} from 'app/common/delay';
|
||||
import {OpenDocMode, UserOverride} from 'app/common/DocListAPI';
|
||||
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
|
||||
import {Product} from 'app/common/Features';
|
||||
import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
|
||||
import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
|
||||
import {getReconnectTimeout} from 'app/common/gutil';
|
||||
import {canEdit, isOwner} from 'app/common/roles';
|
||||
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
|
||||
@@ -39,6 +39,8 @@ export interface DocInfo extends Document {
|
||||
userOverride: UserOverride|null;
|
||||
isBareFork: boolean; // a document created without logging in, which is treated as a
|
||||
// fork without an original.
|
||||
isTutorialTrunk: boolean;
|
||||
isTutorialFork: boolean;
|
||||
idParts: UrlIdParts;
|
||||
openMode: OpenDocMode;
|
||||
}
|
||||
@@ -70,6 +72,8 @@ export interface DocPageModel {
|
||||
isRecoveryMode: Observable<boolean>;
|
||||
userOverride: Observable<UserOverride|null>;
|
||||
isBareFork: Observable<boolean>;
|
||||
isTutorialTrunk: Observable<boolean>;
|
||||
isTutorialFork: Observable<boolean>;
|
||||
|
||||
importSources: ImportSource[];
|
||||
|
||||
@@ -120,6 +124,10 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
(use, doc) => doc ? doc.isRecoveryMode : false);
|
||||
public readonly userOverride = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.userOverride : null);
|
||||
public readonly isBareFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isBareFork : false);
|
||||
public readonly isTutorialTrunk = Computed.create(this, this.currentDoc,
|
||||
(use, doc) => doc ? doc.isTutorialTrunk : false);
|
||||
public readonly isTutorialFork = Computed.create(this, this.currentDoc,
|
||||
(use, doc) => doc ? doc.isTutorialFork : false);
|
||||
|
||||
public readonly importSources: ImportSource[] = [];
|
||||
|
||||
@@ -226,12 +234,22 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
}
|
||||
|
||||
// Replace the URL without reloading the doc.
|
||||
public updateUrlNoReload(urlId: string, urlOpenMode: OpenDocMode, options: {replace: boolean}) {
|
||||
public updateUrlNoReload(
|
||||
urlId: string,
|
||||
urlOpenMode: OpenDocMode,
|
||||
options: {removeSlug?: boolean, replaceUrl?: boolean} = {removeSlug: false, replaceUrl: true}
|
||||
) {
|
||||
const {removeSlug, replaceUrl} = options;
|
||||
const state = urlState().state.get();
|
||||
const nextState = {...state, doc: urlId, mode: urlOpenMode === 'default' ? undefined : urlOpenMode};
|
||||
const nextState = {
|
||||
...state,
|
||||
doc: urlId,
|
||||
...(removeSlug ? {slug: undefined} : undefined),
|
||||
mode: urlOpenMode === 'default' ? undefined : urlOpenMode,
|
||||
};
|
||||
// We preemptively update _openerDocKey so that the URL update doesn't trigger a reload.
|
||||
this._openerDocKey = this._getDocKey(nextState);
|
||||
return urlState().pushUrl(nextState, {avoidReload: true, ...options});
|
||||
return urlState().pushUrl(nextState, {avoidReload: true, replace: replaceUrl});
|
||||
}
|
||||
|
||||
public offerRecovery(err: Error) {
|
||||
@@ -283,15 +301,41 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
const gristDocModulePromise = loadGristDoc();
|
||||
|
||||
const docResponse = await retryOnNetworkError(flow, getDoc.bind(null, this._api, urlId));
|
||||
const doc = buildDocInfo(docResponse, urlOpenMode);
|
||||
flow.checkIfCancelled();
|
||||
|
||||
if (doc.urlId && doc.urlId !== urlId) {
|
||||
// Replace the URL to reflect the canonical urlId.
|
||||
await this.updateUrlNoReload(doc.urlId, doc.openMode, {replace: true});
|
||||
}
|
||||
let doc = buildDocInfo(docResponse, urlOpenMode);
|
||||
if (doc.isTutorialTrunk) {
|
||||
// We're loading a tutorial, so we need to prepare a URL to a fork of the
|
||||
// tutorial. The URL will either be to an existing fork, or a new fork if this
|
||||
// is the first time the user is opening the tutorial.
|
||||
const fork = doc.forks?.[0];
|
||||
let forkUrlId: string | undefined;
|
||||
if (fork) {
|
||||
// If a fork of this tutorial already exists, prepare to navigate to it.
|
||||
forkUrlId = buildUrlId({
|
||||
trunkId: doc.urlId || doc.id,
|
||||
forkId: fork.id,
|
||||
forkUserId: this.appModel.currentValidUser!.id
|
||||
});
|
||||
} else {
|
||||
// Otherwise, create a new fork and prepare to navigate to it.
|
||||
const forkResult = await this._api.getDocAPI(doc.id).fork();
|
||||
flow.checkIfCancelled();
|
||||
forkUrlId = forkResult.urlId;
|
||||
}
|
||||
// Remove the slug from the fork URL - they don't work with slugs.
|
||||
await this.updateUrlNoReload(forkUrlId, 'default', {removeSlug: true});
|
||||
await this.updateCurrentDoc(forkUrlId, 'default');
|
||||
flow.checkIfCancelled();
|
||||
doc = this.currentDoc.get()!;
|
||||
} else {
|
||||
if (doc.urlId && doc.urlId !== urlId) {
|
||||
// Replace the URL to reflect the canonical urlId.
|
||||
await this.updateUrlNoReload(doc.urlId, doc.openMode);
|
||||
}
|
||||
|
||||
this.currentDoc.set(doc);
|
||||
this.currentDoc.set(doc);
|
||||
}
|
||||
|
||||
// Maintain a connection to doc-worker while opening a document. After it's opened, the DocComm
|
||||
// object created by GristDoc will maintain the connection.
|
||||
@@ -316,7 +360,8 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
// The current document has been forked, and should now be referred to using a new docId.
|
||||
const currentDoc = this.currentDoc.get();
|
||||
if (currentDoc) {
|
||||
await this.updateUrlNoReload(newUrlId, 'default', {replace: false});
|
||||
// Remove the slug from the fork URL - they don't work with slugs.
|
||||
await this.updateUrlNoReload(newUrlId, 'default', {removeSlug: true, replaceUrl: false});
|
||||
await this.updateCurrentDoc(newUrlId, 'default');
|
||||
}
|
||||
});
|
||||
@@ -396,6 +441,8 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
|
||||
|
||||
const isPreFork = (openMode === 'fork');
|
||||
const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE;
|
||||
const isTutorialTrunk = !isFork && doc.type === 'tutorial' && mode !== 'default';
|
||||
const isTutorialFork = isFork && doc.type === 'tutorial';
|
||||
const isEditable = canEdit(doc.access) || isPreFork;
|
||||
return {
|
||||
...doc,
|
||||
@@ -404,6 +451,8 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
|
||||
userOverride: null, // ditto.
|
||||
isPreFork,
|
||||
isBareFork,
|
||||
isTutorialTrunk,
|
||||
isTutorialFork,
|
||||
isReadonly: !isEditable,
|
||||
idParts,
|
||||
openMode,
|
||||
|
||||
@@ -14,6 +14,7 @@ export function createPageRec(this: PageRec, docModel: DocModel): void {
|
||||
// Page is hidden when any of this is true:
|
||||
// - It has an empty name (or no name at all)
|
||||
// - It is GristDocTour (unless user wants to see it)
|
||||
// - It is GristDocTutorial (and the document is a tutorial fork)
|
||||
// - It is a page generated for a hidden table TODO: Follow up - don't create
|
||||
// pages for hidden tables.
|
||||
// This is used currently only the left panel, to hide pages from the user.
|
||||
@@ -26,7 +27,11 @@ export function createPageRec(this: PageRec, docModel: DocModel): void {
|
||||
const primaryTable = tables.find(t => t.primaryViewId() === viewId);
|
||||
return !!primaryTable && primaryTable.tableId()?.startsWith("GristHidden_");
|
||||
};
|
||||
return (name === 'GristDocTour' && !docModel.showDocTourTable) || isTableHidden();
|
||||
return (
|
||||
(name === 'GristDocTour' && !docModel.showDocTourTable) ||
|
||||
(name === 'GristDocTutorial' && !docModel.showDocTutorialTable) ||
|
||||
isTableHidden()
|
||||
);
|
||||
});
|
||||
this.isHidden = ko.pureComputed(() => {
|
||||
return this.isCensored() || this.isSpecial();
|
||||
|
||||
Reference in New Issue
Block a user