gristlabs_grist-core/app/client/models/HomeModel.ts
George Gevoian 24fc3a2d00 (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
2021-07-28 12:29:03 -07:00

383 lines
17 KiB
TypeScript

import {guessTimezone} from 'app/client/lib/guessTimezone';
import {localStorageObs} from 'app/client/lib/localStorageObs';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {UserError} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState';
import {ownerName} from 'app/client/models/WorkspaceInfo';
import {IHomePage} from 'app/common/gristUrls';
import {isLongerThan} from 'app/common/gutil';
import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs';
import * as roles from 'app/common/roles';
import {Document, Workspace} from 'app/common/UserAPI';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
import flatten = require('lodash/flatten');
import sortBy = require('lodash/sortBy');
import * as moment from 'moment';
const DELAY_BEFORE_SPINNER_MS = 500;
// Given a UTC Date ISO 8601 string (the doc updatedAt string), gives a reader-friendly
// relative time to now - e.g. 'yesterday', '2 days ago'.
export function getTimeFromNow(utcDateISO: string): string {
const time = moment.utc(utcDateISO);
const now = moment();
const diff = now.diff(time, 's');
if (diff < 0 && diff > -60) {
// If the time appears to be in the future, but less than a minute
// in the future, chalk it up to a difference in time
// synchronization and don't claim the resource will be changed in
// the future. For larger differences, just report them
// literally, there's a more serious problem or lack of
// synchronization.
return now.fromNow();
}
return time.fromNow();
}
export interface HomeModel {
// PageType value, one of the discriminated union values used by AppModel.
pageType: "home";
app: AppModel;
currentPage: Observable<IHomePage>;
currentWSId: Observable<number|undefined>; // should be set when currentPage is 'workspace'
// Note that Workspace contains its documents in .docs.
workspaces: Observable<Workspace[]>;
loading: Observable<boolean|"slow">; // Set to "slow" when loading for a while.
available: Observable<boolean>; // set if workspaces loaded correctly.
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>;
// 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>;
// The workspace for new docs, or "unsaved" to only allow unsaved-doc creation, or null if the
// user isn't allowed to create a doc.
newDocWorkspace: Observable<Workspace|null|"unsaved">;
createWorkspace(name: string): Promise<void>;
renameWorkspace(id: number, name: string): Promise<void>;
deleteWorkspace(id: number, forever: boolean): Promise<void>;
restoreWorkspace(ws: Workspace): Promise<void>;
createDoc(name: string, workspaceId: number|"unsaved"): Promise<string>;
renameDoc(docId: string, name: string): Promise<void>;
deleteDoc(docId: string, forever: boolean): Promise<void>;
restoreDoc(doc: Document): Promise<void>;
pinUnpinDoc(docId: string, pin: boolean): Promise<void>;
moveDoc(docId: string, workspaceId: number): Promise<void>;
}
export interface ViewSettings {
currentSort: Observable<SortPref>;
currentView: Observable<ViewPref>;
}
export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings {
public readonly pageType = "home";
public readonly currentPage = Computed.create(this, urlState().state, (use, s) =>
s.homePage || (s.ws !== undefined ? "workspace" : "all"));
public readonly currentWSId = Computed.create(this, urlState().state, (use, s) => s.ws);
public readonly workspaces = Observable.create<Workspace[]>(this, []);
public readonly loading = Observable.create<boolean|"slow">(this, true);
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) =>
use(this.workspaces).find(ws => (ws.id === use(this.currentWSId))));
public readonly currentWSPinnedDocs = Computed.create(this, this.currentPage, this.currentWS, (use, page, ws) => {
const docs = (page === 'all') ?
flatten((use(this.workspaces).map(w => w.docs))) :
(ws ? ws.docs : []);
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>;
// The workspace for new docs, or "unsaved" to only allow unsaved-doc creation, or null if the
// user isn't allowed to create a doc.
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 (['templates', 'trash'].includes(page)) { return null; }
const destWS = (page === 'all') ? (use(this.workspaces)[0] || null) : ws;
return destWS && roles.canEdit(destWS.access) ? destWS : null;
});
// Whether to show intro: no docs (other than examples) and user may create docs.
public readonly showIntro = Computed.create(this, this.workspaces, (use, wss) => (
wss.every((ws) => ws.isSupportWorkspace || ws.docs.length === 0) &&
Boolean(use(this.newDocWorkspace))));
private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);
constructor(private _app: AppModel) {
super();
if (!this.app.currentValidUser) {
// For the anonymous user, use local settings, don't attempt to save anything to the server.
const viewSettings = makeLocalViewSettings(null, 'all');
this.currentSort = viewSettings.currentSort;
this.currentView = viewSettings.currentView;
} else {
// Preference for sorting. Defaults to 'name'. Saved to server on write.
this.currentSort = Computed.create(this, this._userOrgPrefs,
(use, prefs) => SortPref.parse(prefs?.docMenuSort) || 'name')
.onWrite(s => this._saveUserOrgPref("docMenuSort", s));
// Preference for view mode. The default is somewhat complicated. Saved to server on write.
this.currentView = Computed.create(this, this._userOrgPrefs,
(use, prefs) => ViewPref.parse(prefs?.docMenuView) || getViewPrefDefault(use(this.workspaces)))
.onWrite(s => this._saveUserOrgPref("docMenuView", s));
}
this.autoDispose(subscribe(this.currentPage, this.currentWSId, (use) =>
this._updateWorkspaces().catch(reportError)));
}
// Accessor for the AppModel containing this HomeModel.
public get app(): AppModel { return this._app; }
public async createWorkspace(name: string) {
const org = this._app.currentOrg;
if (!org) { return; }
this._checkForDuplicates(name);
await this._app.api.newWorkspace({name}, org.id);
await this._updateWorkspaces();
}
public async renameWorkspace(id: number, name: string) {
this._checkForDuplicates(name);
await this._app.api.renameWorkspace(id, name);
await this._updateWorkspaces();
}
public async deleteWorkspace(id: number, forever: boolean) {
// TODO: Prevent the last workspace from being removed.
await (forever ? this._app.api.deleteWorkspace(id) : this._app.api.softDeleteWorkspace(id));
await this._updateWorkspaces();
}
public async restoreWorkspace(ws: Workspace) {
await this._app.api.undeleteWorkspace(ws.id);
await this._updateWorkspaces();
reportError(new UserError(`Workspace "${ws.name}" restored`));
}
// Creates a new doc by calling the API, and returns its docId.
public async createDoc(name: string, workspaceId: number|"unsaved"): Promise<string> {
if (workspaceId === "unsaved") {
const timezone = await guessTimezone();
return await this._app.api.newUnsavedDoc({timezone});
}
const id = await this._app.api.newDoc({name}, workspaceId);
await this._updateWorkspaces();
return id;
}
public async renameDoc(docId: string, name: string): Promise<void> {
await this._app.api.renameDoc(docId, name);
await this._updateWorkspaces();
}
public async deleteDoc(docId: string, forever: boolean): Promise<void> {
await (forever ? this._app.api.deleteDoc(docId) : this._app.api.softDeleteDoc(docId));
await this._updateWorkspaces();
}
public async restoreDoc(doc: Document): Promise<void> {
await this._app.api.undeleteDoc(doc.id);
await this._updateWorkspaces();
reportError(new UserError(`Document "${doc.name}" restored`));
}
public async pinUnpinDoc(docId: string, pin: boolean): Promise<void> {
await (pin ? this._app.api.pinDoc(docId) : this._app.api.unpinDoc(docId));
await this._updateWorkspaces();
}
public async moveDoc(docId: string, workspaceId: number): Promise<void> {
await this._app.api.moveDoc(docId, workspaceId);
await this._updateWorkspaces();
}
private _checkForDuplicates(name: string): void {
if (this.workspaces.get().find(ws => ws.name === name)) {
throw new UserError('Name already exists. Please choose a different name.');
}
}
// Fetches and updates workspaces, which include contained docs as well.
private async _updateWorkspaces() {
const org = this._app.currentOrg;
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 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());
// Populate doc.removedAt for soft-deleted docs even when deleted along with a workspace.
if (forRemoved) {
for (const doc of ws.docs) {
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
// orgs are grouped by personal org and the groups are ordered alphabetically
// by owner name; and all else being equal workspaces are ordered alphabetically
// by their name. All alphabetical ordering is case-insensitive.
// Workspaces shared from support account (e.g. samples) are put last.
return sortBy(wss, (ws) => [ws.isSupportWorkspace,
ownerName(this._app, ws).toLowerCase(),
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) {
org.userOrgPrefs = {...org.userOrgPrefs, [key]: value};
this._userOrgPrefs.set(org.userOrgPrefs);
await this._app.api.updateOrg('current', {userOrgPrefs: org.userOrgPrefs});
}
}
}
// Check if active product allows just a single workspace.
function _isSingleWorkspaceMode(app: AppModel): boolean {
return app.currentFeatures.maxWorkspacesPerOrg === 1;
}
// Returns a default view mode preference. We used to show 'list' for everyone. We now default to
// 'icons' for new or light users. But if a user has more than 4 docs or any pinned docs, we'll
// switch to 'list'. This will also avoid annoying existing users who may prefer a list.
function getViewPrefDefault(workspaces: Workspace[]): ViewPref {
const userWorkspaces = workspaces.filter(ws => !ws.isSupportWorkspace);
const numDocs = userWorkspaces.reduce((sum, ws) => sum + ws.docs.length, 0);
const pinnedDocs = userWorkspaces.some((ws) => ws.docs.some(doc => doc.isPinned));
return (numDocs > 4 || pinnedDocs) ? 'list' : 'icons';
}
/**
* 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'|'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`);
return {
currentSort: Computed.create(null,
// If no value in localStorage, use sort of All Documents.
(use) => SortPref.parse(use(sort)) || (home ? use(home.currentSort) : 'name'))
.onWrite((val) => sort.set(val)),
currentView: Computed.create(null,
// If no value in localStorage, use mode of All Documents, except Trash which defaults to 'list'.
(use) => ViewPref.parse(use(view)) || (wsId === 'trash' ? 'list' : (home ? use(home.currentView) : 'icons')))
.onWrite((val) => view.set(val)),
};
}