(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:
George Gevoian
2023-03-22 09:48:50 -04:00
parent 210aa92eed
commit be8e13df64
31 changed files with 1621 additions and 174 deletions

View File

@@ -231,8 +231,12 @@ export function attachAppEndpoint(options: AttachOptions): void {
// Query DB for the doc metadata, to include in the page (as a pre-fetch of getDoc() call),
// and to get fresh (uncached) access info.
doc = await dbManager.getDoc({userId, org: mreq.org, urlId});
const slug = getSlugIfNeeded(doc);
if (isAnonymousUser(mreq) && doc.type === 'tutorial') {
// Tutorials require users to be signed in.
throw new ApiError('You must be signed in to access a tutorial.', 403);
}
const slug = getSlugIfNeeded(doc);
const slugMismatch = (req.params.slug || null) !== (slug || null);
const preferredUrlId = doc.urlId || doc.id;
if (urlId !== preferredUrlId || slugMismatch) {
@@ -263,8 +267,8 @@ export function attachAppEndpoint(options: AttachOptions): void {
// First check if anonymous user has access to this org. If so, we don't propose
// that they log in. This is the same check made in redirectToLogin() middleware.
const result = await dbManager.getOrg({userId: getUserId(mreq)}, mreq.org || null);
if (result.status !== 200) {
// Anonymous user does not have any access to this org, or to this doc.
if (result.status !== 200 || doc?.type === 'tutorial') {
// Anonymous user does not have any access to this org, doc, or tutorial.
// Redirect to log in.
return forceLogin(req, res, next);
}

View File

@@ -9,7 +9,7 @@ import {SortFunc} from 'app/common/SortFunc';
import {Sort} from 'app/common/SortSpec';
import {MetaRowRecord} from 'app/common/TableData';
import {DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
import {HomeDBManager, makeDocAuthResult, QueryResult} from 'app/gen-server/lib/HomeDBManager';
import {HomeDBManager, makeDocAuthResult} from 'app/gen-server/lib/HomeDBManager';
import * as Types from "app/plugin/DocApiTypes";
import DocApiTypesTI from "app/plugin/DocApiTypes-ti";
import GristDataTI from 'app/plugin/GristData-ti';
@@ -784,6 +784,27 @@ export class DocWorkerApi {
'Content-Type': 'application/json',
}
});
if (req.body.resetTutorialMetadata) {
const scope = getDocScope(req);
const tutorialTrunkId = options.sourceDocId;
await this._dbManager.connection.transaction(async (manager) => {
// Fetch the tutorial trunk doc so we can replace the tutorial doc's name.
const tutorialTrunk = await this._dbManager.getRawDocById(tutorialTrunkId, manager);
await this._dbManager.updateDocument(
scope,
{
name: tutorialTrunk.name,
options: {
tutorial: {
// For now, the only state we need to reset is the slide position.
lastSlideIndex: 0,
},
},
},
manager
);
});
}
}
if (req.body.snapshotId) {
options.snapshotId = String(req.body.snapshotId);
@@ -1216,12 +1237,7 @@ export class DocWorkerApi {
];
await Promise.all(docsToDelete.map(docName => this._docManager.deleteDoc(null, docName, true)));
// Permanently delete from database.
let query: QueryResult<number>;
if (forkId) {
query = await this._dbManager.deleteFork({...scope, urlId: forkId});
} else {
query = await this._dbManager.deleteDocument(scope);
}
const query = await this._dbManager.deleteDocument(scope);
this._dbManager.checkQueryResult(query);
await sendReply(req, res, query);
} else {