(core) Redesign examples and templates UI

Summary:
The old Examples and Templates workspace is now
a page that pulls templates from a new public Grist Templates org.
The All Documents view will pull featured templates from that org, where
featured templates are simply pinned documents in Grist Templates. The
Examples and Templates page will also show the featured templates, as
well as the rest of the available templates organized by category. The
categories are equivalent to workspaces in Grist Templates, and are
generated dynamically.

Test Plan: Browser tests.

Reviewers: paulfitz, dsagal

Reviewed By: paulfitz, dsagal

Subscribers: dsagal, paulfitz, jarek

Differential Revision: https://phab.getgrist.com/D2930
This commit is contained in:
George Gevoian
2021-07-28 12:02:06 -07:00
parent bb8cb2593d
commit 24fc3a2d00
19 changed files with 594 additions and 311 deletions

View File

@@ -28,7 +28,6 @@ import {Holder, Observable, subscribe} from 'grainjs';
export interface DocInfo extends Document {
isReadonly: boolean;
isSample: boolean;
isPreFork: boolean;
isFork: boolean;
isRecoveryMode: boolean;
@@ -59,7 +58,6 @@ export interface DocPageModel {
isRecoveryMode: Observable<boolean>;
userOverride: Observable<UserOverride|null>;
isBareFork: Observable<boolean>;
isSample: Observable<boolean>;
importSources: ImportSource[];
@@ -98,7 +96,6 @@ 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 isSample = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isSample : false);
public readonly importSources: ImportSource[] = [];
@@ -123,7 +120,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
this.autoDispose(subscribe(urlState().state, (use, state) => {
const urlId = state.doc;
const urlOpenMode = state.mode || 'default';
const urlOpenMode = state.mode;
const linkParameters = state.params?.linkParameters;
const docKey = this._getDocKey(state);
if (docKey !== this._openerDocKey) {
@@ -226,7 +223,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
);
}
private async _openDoc(flow: AsyncFlow, urlId: string, urlOpenMode: OpenDocMode,
private async _openDoc(flow: AsyncFlow, urlId: string, urlOpenMode: OpenDocMode | undefined,
comparisonUrlId: string | undefined,
linkParameters: Record<string, string> | undefined): Promise<void> {
console.log(`DocPageModel _openDoc starting for ${urlId} (mode ${urlOpenMode})` +
@@ -326,11 +323,21 @@ function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly:
];
}
function buildDocInfo(doc: Document, mode: OpenDocMode): DocInfo {
function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
const idParts = parseUrlId(doc.urlId || doc.id);
const isFork = Boolean(idParts.forkId || idParts.snapshotId);
const isSample = !isFork && Boolean(doc.workspace.isSupportWorkspace);
const openMode = isSample ? 'fork' : mode;
let openMode = mode;
if (!openMode) {
if (isFork) {
// Ignore the document 'openMode' setting if the doc is an unsaved fork.
openMode = 'default';
} else {
// Try to use the document's 'openMode' if it's set.
openMode = doc.options?.openMode ?? 'default';
}
}
const isPreFork = (openMode === 'fork');
const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE;
const isEditable = canEdit(doc.access) || isPreFork;
@@ -339,7 +346,6 @@ function buildDocInfo(doc: Document, mode: OpenDocMode): DocInfo {
isFork,
isRecoveryMode: false, // we don't know yet, will learn when doc is opened.
userOverride: null, // ditto.
isSample,
isPreFork,
isBareFork,
isReadonly: !isEditable,

View File

@@ -49,6 +49,7 @@ export interface HomeModel {
showIntro: Observable<boolean>; // set if no docs and we should show intro.
singleWorkspace: Observable<boolean>; // set if workspace name should be hidden.
trashWorkspaces: Observable<Workspace[]>; // only set when viewing trash
templateWorkspaces: Observable<Workspace[]>; // Only set when viewing templates or all documents.
// currentWS is undefined when currentPage is not "workspace" or if currentWSId doesn't exist.
currentWS: Observable<Workspace|undefined>;
@@ -56,6 +57,9 @@ export interface HomeModel {
// List of pinned docs to show for currentWS.
currentWSPinnedDocs: Observable<Document[]>;
// List of featured templates from templateWorkspaces.
featuredTemplates: Observable<Document[]>;
currentSort: Observable<SortPref>;
currentView: Observable<ViewPref>;
@@ -91,6 +95,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
public readonly available = Observable.create(this, false);
public readonly singleWorkspace = Observable.create(this, true);
public readonly trashWorkspaces = Observable.create<Workspace[]>(this, []);
public readonly templateWorkspaces = Observable.create<Workspace[]>(this, []);
// Get the workspace details for the workspace with id of currentWSId.
public readonly currentWS = Computed.create(this, (use) =>
@@ -103,6 +108,11 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
return sortBy(docs.filter(doc => doc.isPinned), (doc) => doc.name.toLowerCase());
});
public readonly featuredTemplates = Computed.create(this, this.templateWorkspaces, (_use, templates) => {
const featuredTemplates = flatten((templates).map(t => t.docs)).filter(t => t.isPinned);
return sortBy(featuredTemplates, (t) => t.name.toLowerCase());
});
public readonly currentSort: Observable<SortPref>;
public readonly currentView: Observable<ViewPref>;
@@ -111,7 +121,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
public readonly newDocWorkspace = Computed.create(this, this.currentPage, this.currentWS, (use, page, ws) => {
// Anonymous user can create docs, but in unsaved mode.
if (!this.app.currentValidUser) { return "unsaved"; }
if (page === 'trash') { return null; }
if (['templates', 'trash'].includes(page)) { return null; }
const destWS = (page === 'all') ? (use(this.workspaces)[0] || null) : ws;
return destWS && roles.canEdit(destWS.access) ? destWS : null;
});
@@ -222,48 +232,59 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
// Fetches and updates workspaces, which include contained docs as well.
private async _updateWorkspaces() {
const org = this._app.currentOrg;
if (org) {
this.loading.set(true);
const promises = Promise.all([
this._fetchWorkspaces(org.id, false).catch(reportError),
(this.currentPage.get() === 'trash') ? this._fetchWorkspaces(org.id, true).catch(reportError) : null,
]);
if (await isLongerThan(promises, DELAY_BEFORE_SPINNER_MS)) {
this.loading.set("slow");
}
const [wss, trashWss] = await promises;
// bundleChanges defers computeds' evaluations until all changes have been applied.
bundleChanges(() => {
this.workspaces.set(wss || []);
this.trashWorkspaces.set(trashWss || []);
this.loading.set(false);
this.available.set(!!wss);
// Hide workspace name if we are showing a single workspace, and active product
// doesn't allow adding workspaces. It is important to check both conditions because
// * A personal org, where workspaces can't be added, can still have multiple
// workspaces via documents shared by other users.
// * An org with workspace support might happen to just have one workspace right
// now, but it is good to show names to highlight the possibility of adding more.
this.singleWorkspace.set(!!wss && wss.length === 1 && _isSingleWorkspaceMode(this._app));
});
} else {
if (!org) {
this.workspaces.set([]);
this.trashWorkspaces.set([]);
this.templateWorkspaces.set([]);
return;
}
this.loading.set(true);
const currentPage = this.currentPage.get();
const promises = [
this._fetchWorkspaces(org.id, false).catch(reportError), // workspaces
currentPage === 'trash' ? this._fetchWorkspaces(org.id, true).catch(reportError) : null, // trash
null // templates
];
const shouldFetchTemplates = ['all', 'templates'].includes(currentPage);
if (shouldFetchTemplates) {
const onlyFeatured = currentPage === 'all';
promises[2] = this._fetchTemplates(onlyFeatured);
}
const promise = Promise.all(promises);
if (await isLongerThan(promise, DELAY_BEFORE_SPINNER_MS)) {
this.loading.set("slow");
}
const [wss, trashWss, templateWss] = await promise;
// bundleChanges defers computeds' evaluations until all changes have been applied.
bundleChanges(() => {
this.workspaces.set(wss || []);
this.trashWorkspaces.set(trashWss || []);
this.templateWorkspaces.set(templateWss || []);
this.loading.set(false);
this.available.set(!!wss);
// Hide workspace name if we are showing a single (non-support) workspace, and active
// product doesn't allow adding workspaces. It is important to check both conditions because:
// * A personal org, where workspaces can't be added, can still have multiple
// workspaces via documents shared by other users.
// * An org with workspace support might happen to just have one workspace right
// now, but it is good to show names to highlight the possibility of adding more.
const nonSupportWss = Array.isArray(wss) ? wss.filter(ws => !ws.isSupportWorkspace) : null;
this.singleWorkspace.set(
!!nonSupportWss && nonSupportWss.length === 1 && _isSingleWorkspaceMode(this._app)
);
});
}
private async _fetchWorkspaces(orgId: number, forRemoved: boolean) {
let wss: Workspace[] = [];
try {
if (forRemoved) {
wss = await this._app.api.forRemoved().getOrgWorkspaces(orgId);
} else {
wss = await this._app.api.getOrgWorkspaces(orgId);
}
} catch (e) {
return null;
let api = this._app.api;
if (forRemoved) {
api = api.forRemoved();
}
const wss = await api.getOrgWorkspaces(orgId);
if (this.isDisposed()) { return null; }
for (const ws of wss) {
ws.docs = sortBy(ws.docs, (doc) => doc.name.toLowerCase());
@@ -274,6 +295,13 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
doc.removedAt = doc.removedAt || ws.removedAt;
}
}
// Populate doc.workspace, which is used by DocMenu/PinnedDocs and
// is useful in cases where there are multiple workspaces containing
// pinned documents that need to be sorted in alphabetical order.
for (const doc of ws.docs) {
doc.workspace = doc.workspace ?? ws;
}
}
// Sort workspaces such that workspaces from the personal orgs of others
// come after workspaces from our own personal org; workspaces from personal
@@ -286,6 +314,27 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
ws.name.toLowerCase()]);
}
private async _fetchTemplates(onlyFeatured: boolean) {
let templateWss: Workspace[] = [];
try {
templateWss = await this._app.api.getTemplates(onlyFeatured);
} catch {
// If the org doesn't exist (404), return nothing and don't report error to user.
return null;
}
if (this.isDisposed()) { return null; }
for (const ws of templateWss) {
for (const doc of ws.docs) {
// Populate doc.workspace, which is used by DocMenu/PinnedDocs and
// is useful in cases where there are multiple workspaces containing
// pinned documents that need to be sorted in alphabetical order.
doc.workspace = doc.workspace ?? ws;
}
ws.docs = sortBy(ws.docs, (doc) => doc.name.toLowerCase());
}
return templateWss;
}
private async _saveUserOrgPref<K extends keyof UserOrgPrefs>(key: K, value: UserOrgPrefs[K]) {
const org = this._app.currentOrg;
if (org) {
@@ -315,7 +364,7 @@ function getViewPrefDefault(workspaces: Workspace[]): ViewPref {
* Create observables for per-workspace view settings which default to org-wide settings, but can
* be changed independently and persisted in localStorage.
*/
export function makeLocalViewSettings(home: HomeModel|null, wsId: number|'trash'|'all'): ViewSettings {
export function makeLocalViewSettings(home: HomeModel|null, wsId: number|'trash'|'all'|'templates'): ViewSettings {
const userId = home?.app.currentUser?.id || 0;
const sort = localStorageObs(`u=${userId}:ws=${wsId}:sort`);
const view = localStorageObs(`u=${userId}:ws=${wsId}:view`);

View File

@@ -37,9 +37,23 @@ export function urlState(): UrlState<IGristUrlState> {
}
let _urlState: UrlState<IGristUrlState>|undefined;
// Returns url parameters appropriate for the specified document, specifically `doc` and `slug`.
export function docUrl(doc: Document): IGristUrlState {
return {doc: doc.urlId || doc.id, slug: getSlugIfNeeded(doc)};
/**
* Returns url parameters appropriate for the specified document.
*
* In addition to setting `doc` and `slug`, it sets additional parameters
* from `params` if any are supplied.
*/
export function docUrl(doc: Document, params: {org?: string} = {}): IGristUrlState {
const state: IGristUrlState = {
doc: doc.urlId || doc.id,
slug: getSlugIfNeeded(doc),
};
// TODO: Get non-sample documents with `org` set to fully work (a few tests fail).
if (params.org) {
state.org = params.org;
}
return state;
}
// Returns the home page for the current org.