mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
74ec9358da
Summary: Also fixes a minor CSS regression in UserManager where the link to add a team member wasn't shown on a separate row. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3444
434 lines
18 KiB
TypeScript
434 lines
18 KiB
TypeScript
import {ClientScope} from 'app/client/components/ClientScope';
|
|
import {guessTimezone} from 'app/client/lib/guessTimezone';
|
|
import {HomePluginManager} from 'app/client/lib/HomePluginManager';
|
|
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
|
|
import {localStorageObs} from 'app/client/lib/localStorageObs';
|
|
import {AppModel, reportError} from 'app/client/models/AppModel';
|
|
import {reportMessage, UserError} from 'app/client/models/errors';
|
|
import {urlState} from 'app/client/models/gristUrlState';
|
|
import {ownerName} from 'app/client/models/WorkspaceInfo';
|
|
import {OrgUsageSummary} from 'app/common/DocUsage';
|
|
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, Organization, Workspace} from 'app/common/UserAPI';
|
|
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
|
|
import * as moment from 'moment';
|
|
import flatten = require('lodash/flatten');
|
|
import sortBy = require('lodash/sortBy');
|
|
|
|
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[]>;
|
|
|
|
// List of other sites (orgs) user can access. Only populated on All Documents, and only when
|
|
// the current org is a personal org, or the current org is view access only.
|
|
otherSites: Observable<Organization[]>;
|
|
|
|
currentSort: Observable<SortPref>;
|
|
currentView: Observable<ViewPref>;
|
|
importSources: Observable<ImportSourceElement[]>;
|
|
|
|
// 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">;
|
|
|
|
currentOrgUsage: Observable<OrgUsageSummary|null>;
|
|
|
|
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, []);
|
|
public readonly importSources = Observable.create<ImportSourceElement[]>(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 otherSites = Computed.create(this, this.currentPage, this.app.topAppModel.orgs,
|
|
(_use, page, orgs) => {
|
|
if (page !== 'all') { return []; }
|
|
|
|
const currentOrg = this._app.currentOrg;
|
|
if (!currentOrg) { return []; }
|
|
|
|
const isPersonalOrg = currentOrg.owner;
|
|
if (!isPersonalOrg && (currentOrg.access !== 'viewers' || !currentOrg.public)) {
|
|
return [];
|
|
}
|
|
|
|
return orgs.filter(org => org.id !== currentOrg.id);
|
|
});
|
|
|
|
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 (page === 'trash') { return null; }
|
|
const destWS = (['all', 'templates'].includes(page)) ? (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))));
|
|
|
|
public readonly currentOrgUsage: Observable<OrgUsageSummary|null> = Observable.create(this, null);
|
|
|
|
private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);
|
|
|
|
constructor(private _app: AppModel, clientScope: ClientScope) {
|
|
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)));
|
|
|
|
// Defer home plugin initialization
|
|
const pluginManager = new HomePluginManager(
|
|
_app.topAppModel.plugins,
|
|
_app.topAppModel.getUntrustedContentOrigin()!,
|
|
clientScope);
|
|
const importSources = ImportSourceElement.fromArray(pluginManager.pluginsList);
|
|
this.importSources.set(importSources);
|
|
|
|
this._updateCurrentOrgUsage().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();
|
|
reportMessage(`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();
|
|
reportMessage(`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),
|
|
currentPage === 'trash' ? this._fetchWorkspaces(org.id, true).catch(reportError) : null,
|
|
this._maybeFetchTemplates(),
|
|
] as const;
|
|
|
|
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()]);
|
|
}
|
|
|
|
/**
|
|
* Fetches templates if on the Templates or All Documents page.
|
|
*
|
|
* Only fetches featured (pinned) templates on the All Documents page.
|
|
*/
|
|
private async _maybeFetchTemplates(): Promise<Workspace[] | null> {
|
|
const currentPage = this.currentPage.get();
|
|
const shouldFetchTemplates = ['all', 'templates'].includes(currentPage);
|
|
if (!shouldFetchTemplates) { return null; }
|
|
|
|
let templateWss: Workspace[] = [];
|
|
try {
|
|
const onlyFeatured = currentPage === 'all';
|
|
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});
|
|
}
|
|
}
|
|
|
|
private async _updateCurrentOrgUsage() {
|
|
const currentOrg = this.app.currentOrg;
|
|
if (!roles.isOwner(currentOrg)) { return; }
|
|
|
|
const api = this.app.api;
|
|
this.currentOrgUsage.set(await api.getOrgUsageSummary(currentOrg.id));
|
|
}
|
|
}
|
|
|
|
// 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)),
|
|
};
|
|
}
|