You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/client/models/DocPageModel.ts

385 lines
16 KiB

import {GristDoc} from 'app/client/components/GristDoc';
import {IUndoState} from 'app/client/components/UndoStack';
import {loadGristDoc} from 'app/client/lib/imports';
import {AppModel, getOrgNameOrGuest, reportError} from 'app/client/models/AppModel';
import {getDoc} from 'app/client/models/gristConfigCache';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
import {App} from 'app/client/ui/App';
import {cssLeftPanel, cssScrollPane} from 'app/client/ui/LeftPanelCommon';
import {buildPagesDom} from 'app/client/ui/Pages';
import {openPageWidgetPicker} from 'app/client/ui/PageWidgetPicker';
import {tools} from 'app/client/ui/Tools';
import {bigBasicButton} from 'app/client/ui2018/buttons';
import {testId} from 'app/client/ui2018/cssVars';
import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
import {delay} from 'app/common/delay';
import {OpenDocMode, UserOverride} from 'app/common/DocListAPI';
import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
import {getReconnectTimeout} from 'app/common/gutil';
import {canEdit} from 'app/common/roles';
import {DataLimitStatus, RowCount} from 'app/common/Usage';
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
import {Holder, Observable, subscribe} from 'grainjs';
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
// tslint:disable:no-console
export interface DocInfo extends Document {
isReadonly: boolean;
isPreFork: boolean;
isFork: boolean;
isRecoveryMode: boolean;
userOverride: UserOverride|null;
isBareFork: boolean; // a document created without logging in, which is treated as a
// fork without an original.
idParts: UrlIdParts;
openMode: OpenDocMode;
}
export interface DocPageModel {
pageType: "doc";
appModel: AppModel;
currentDoc: Observable<DocInfo|null>;
// This block is to satisfy previous interface, but usable as this.currentDoc.get().id, etc.
currentDocId: Observable<string|undefined>;
currentWorkspace: Observable<Workspace|null>;
// We may be given information about the org, because of our access to the doc, that
// we can't get otherwise.
currentOrg: Observable<Organization|null>;
currentOrgName: Observable<string>;
currentDocTitle: Observable<string>;
isReadonly: Observable<boolean>;
isPrefork: Observable<boolean>;
isFork: Observable<boolean>;
isRecoveryMode: Observable<boolean>;
userOverride: Observable<UserOverride|null>;
isBareFork: Observable<boolean>;
importSources: ImportSource[];
undoState: Observable<IUndoState|null>; // See UndoStack for details.
gristDoc: Observable<GristDoc|null>; // Instance of GristDoc once it exists.
rowCount: Observable<RowCount>;
(core) Grace period and delete-only mode when exceeding row limit Summary: Builds upon https://phab.getgrist.com/D3328 - Add HomeDB column `Document.gracePeriodStart` - When the row count moves above the limit, set it to the current date. When it moves below, set it to null. - Add DataLimitStatus type indicating if the document is approaching the limit, is in a grace period, or is in delete only mode if the grace period started at least 14 days ago. Compute it in ActiveDoc and send it to client when opening. - Only allow certain user actions when in delete-only mode. Follow-up tasks related to this diff: - When DataLimitStatus in the client is non-empty, show a banner to the appropriate users. - Only send DataLimitStatus to users with the appropriate access. There's no risk landing this now since real users will only see null until free team sites are released. - Update DataLimitStatus immediately in the client when it changes, e.g. when user actions are applied or the product is changed. Right now it's only sent when the document loads. - Update row limit, grace period start, and data limit status in ActiveDoc when the product changes, i.e. the user upgrades/downgrades. - Account for data size when computing data limit status, not just row counts. See also the tasks mentioned in https://phab.getgrist.com/D3331 Test Plan: Extended FreeTeam nbrowser test, testing the 4 statuses. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3331
2 years ago
dataLimitStatus: Observable<DataLimitStatus|undefined>;
createLeftPane(leftPanelOpen: Observable<boolean>): DomArg;
renameDoc(value: string): Promise<void>;
updateCurrentDoc(urlId: string, openMode: OpenDocMode): Promise<Document>;
refreshCurrentDoc(doc: DocInfo): Promise<Document>;
}
export interface ImportSource {
label: string;
action: () => void;
}
export class DocPageModelImpl extends Disposable implements DocPageModel {
public readonly pageType = "doc";
public readonly currentDoc = Observable.create<DocInfo|null>(this, null);
public readonly currentUrlId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.urlId : undefined);
public readonly currentDocId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.id : undefined);
public readonly currentWorkspace = Computed.create(this, this.currentDoc, (use, doc) => doc && doc.workspace);
public readonly currentOrg = Computed.create(this, this.currentWorkspace, (use, ws) => ws && ws.org);
public readonly currentOrgName = Computed.create(this, this.currentOrg,
(use, org) => getOrgNameOrGuest(org, this.appModel.currentUser));
public readonly currentDocTitle = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.name : '');
public readonly isReadonly = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isReadonly : false);
public readonly isPrefork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isPreFork : false);
public readonly isFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isFork : false);
public readonly isRecoveryMode = Computed.create(this, this.currentDoc,
(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 importSources: ImportSource[] = [];
// Contains observables indicating whether undo/redo are disabled. See UndoStack for details.
public readonly undoState: Observable<IUndoState|null> = Observable.create(this, null);
// Observable set to the instance of GristDoc once it's created.
public readonly gristDoc = Observable.create<GristDoc|null>(this, null);
public readonly rowCount = Observable.create<RowCount>(this, 'pending');
(core) Grace period and delete-only mode when exceeding row limit Summary: Builds upon https://phab.getgrist.com/D3328 - Add HomeDB column `Document.gracePeriodStart` - When the row count moves above the limit, set it to the current date. When it moves below, set it to null. - Add DataLimitStatus type indicating if the document is approaching the limit, is in a grace period, or is in delete only mode if the grace period started at least 14 days ago. Compute it in ActiveDoc and send it to client when opening. - Only allow certain user actions when in delete-only mode. Follow-up tasks related to this diff: - When DataLimitStatus in the client is non-empty, show a banner to the appropriate users. - Only send DataLimitStatus to users with the appropriate access. There's no risk landing this now since real users will only see null until free team sites are released. - Update DataLimitStatus immediately in the client when it changes, e.g. when user actions are applied or the product is changed. Right now it's only sent when the document loads. - Update row limit, grace period start, and data limit status in ActiveDoc when the product changes, i.e. the user upgrades/downgrades. - Account for data size when computing data limit status, not just row counts. See also the tasks mentioned in https://phab.getgrist.com/D3331 Test Plan: Extended FreeTeam nbrowser test, testing the 4 statuses. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3331
2 years ago
public readonly dataLimitStatus = Observable.create<DataLimitStatus|undefined>(this, null);
// Combination of arguments needed to open a doc (docOrUrlId + openMod). It's obtained from the
// URL, and when it changes, we need to re-open.
// If making a comparison, the id of the document we are comparing with is also included
// in the openerDocKey.
private _openerDocKey: string = "";
// Holds a FlowRunner for _openDoc, which is essentially a cancellable promise. It gets replaced
// (with the previous promise cancelled) when _openerDocKey changes.
private _openerHolder = Holder.create<FlowRunner>(this);
constructor(private _appObj: App, public readonly appModel: AppModel, private _api: UserAPI = appModel.api) {
super();
this.autoDispose(subscribe(urlState().state, (use, state) => {
const urlId = state.doc;
const urlOpenMode = state.mode;
const linkParameters = state.params?.linkParameters;
const docKey = this._getDocKey(state);
if (docKey !== this._openerDocKey) {
this._openerDocKey = docKey;
this.gristDoc.set(null);
this.currentDoc.set(null);
this.undoState.set(null);
if (!urlId) {
this._openerHolder.clear();
} else {
FlowRunner.create(this._openerHolder,
(flow: AsyncFlow) => this._openDoc(flow, urlId, urlOpenMode, state.params?.compare, linkParameters)
)
.resultPromise.catch(err => this._onOpenError(err));
}
}
}));
}
public createLeftPane(leftPanelOpen: Observable<boolean>) {
return cssLeftPanel(
dom.maybe(this.gristDoc, (activeDoc) => [
addNewButton(leftPanelOpen,
menu(() => addMenu(this.importSources, activeDoc, this.isReadonly.get()), {
placement: 'bottom-start',
// "Add New" menu should have the same width as the "Add New" button that opens it.
stretchToSelector: `.${cssAddNewButton.className}`
}),
(core) Brings welcome tour and hide behind a flag Summary: This diff brings in the new welcome tour. It builds upon `client/ui/OnBoardingPopup` that was committed to that purposes. Per this diff, the tour is accessible behind a flag and won't be visible to user: few caveats listed below needs to be adressed first. This diff also brings few changes to onboarding module. - allow to refer to element with selector - usually dynamic selection of element sounds useful for when the element does not exist yet when the tour starts. But the actual reason when add it here, is to allow selecting the first cell. - if the selector yields undefined (missing element), the popup is simply skipped - got rid of the internal registry to link between popup contents and popup options. All is now define in the same interface. Registry overall felt overkill and not needed. - adds an option to show message as a simple modal that is centered on the screen This diff also brings the new welcome tour and hide it behind a flag CAVEATS that need to be addressed in follow up commit: - The url needs cleanup, #repeat-welcome-tour sticks to it and so even when navigating to home page. This could eventually become an issue: if user opens another document it would starts the onboarding tour again. - For now you have to manually make sure the right panel is opened with the Column tab selected before starting the tour. - On boarding tours were not designed with mobile support in mind. So probably a good idea to disable. - Backend support needs to be done (persistence of first time user). Test Plan: Updated `projects/OnBoardingPopup` and adds new `nbrowser/welcomeTour` To launch the tour: - open any document - open manually the right panel and the field tab - append the flag `#repeat-welcome-tour` at the end of the url in the url bar and reload the page Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2917
3 years ago
testId('dp-add-new'),
dom.cls('tour-add-new'),
),
cssScrollPane(
dom.create(buildPagesDom, activeDoc, leftPanelOpen),
dom.create(tools, activeDoc, leftPanelOpen),
)
]),
);
}
public async renameDoc(value: string): Promise<void> {
// The docId should never be unset when this option is available.
const doc = this.currentDoc.get();
if (doc) {
if (value.length > 0) {
await this._api.renameDoc(doc.id, value).catch(reportError);
const newDoc = await this.refreshCurrentDoc(doc);
// a "slug" component of the URL may change when the document name is changed.
await urlState().pushUrl({...urlState().state.get(), ...docUrl(newDoc)}, {replace: true, avoidReload: true});
} else {
// This error won't be shown to user (caught by editableLabel).
throw new Error(`doc name should not be empty`);
}
}
}
public async updateCurrentDoc(urlId: string, openMode: OpenDocMode) {
// TODO It would be bad if a new doc gets opened while this getDoc() is pending...
const newDoc = await getDoc(this._api, urlId);
this.currentDoc.set(buildDocInfo(newDoc, openMode));
return newDoc;
}
public async refreshCurrentDoc(doc: DocInfo) {
return this.updateCurrentDoc(doc.urlId || doc.id, doc.openMode);
}
// Replace the URL without reloading the doc.
public updateUrlNoReload(urlId: string, urlOpenMode: OpenDocMode, options: {replace: boolean}) {
const state = urlState().state.get();
const nextState = {...state, doc: urlId, 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});
}
private _onOpenError(err: Error) {
if (err instanceof CancelledError) {
// This means that we started loading a new doc before the previous one finished loading.
console.log("DocPageModel _openDoc cancelled");
return;
}
// Expected errors (e.g. Access Denied) produce a separate error page. For unexpected errors,
// show a modal, and include a toast for the sake of the "Report error" link.
reportError(err);
const isOwner = this.currentDoc.get()?.access === 'owners';
confirmModal(
"Error opening document",
"Reload",
async () => window.location.reload(true),
isOwner ? `You can try reloading the document, or using recovery mode. ` +
`Recovery mode opens the document to be fully accessible to owners, and ` +
`inaccessible to others. ` +
`[${err.message}]` : err.message,
{ hideCancel: true,
extraButtons: isOwner ? bigBasicButton('Enter recovery mode', dom.on('click', async () => {
await this._api.getDocAPI(this.currentDocId.get()!).recover(true);
window.location.reload(true);
}), testId('modal-recovery-mode')) : null,
},
);
}
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})` +
(comparisonUrlId ? ` (compare ${comparisonUrlId})` : ''));
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});
}
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.
const comm = this._appObj.comm;
comm.useDocConnection(doc.id);
flow.onDispose(() => comm.releaseDocConnection(doc.id));
const openDocResponse = await comm.openDoc(doc.id, doc.openMode, linkParameters);
if (openDocResponse.recoveryMode || openDocResponse.userOverride) {
doc.isRecoveryMode = Boolean(openDocResponse.recoveryMode);
doc.userOverride = openDocResponse.userOverride || null;
this.currentDoc.set({...doc});
}
this.rowCount.set(openDocResponse.rowCount);
(core) Grace period and delete-only mode when exceeding row limit Summary: Builds upon https://phab.getgrist.com/D3328 - Add HomeDB column `Document.gracePeriodStart` - When the row count moves above the limit, set it to the current date. When it moves below, set it to null. - Add DataLimitStatus type indicating if the document is approaching the limit, is in a grace period, or is in delete only mode if the grace period started at least 14 days ago. Compute it in ActiveDoc and send it to client when opening. - Only allow certain user actions when in delete-only mode. Follow-up tasks related to this diff: - When DataLimitStatus in the client is non-empty, show a banner to the appropriate users. - Only send DataLimitStatus to users with the appropriate access. There's no risk landing this now since real users will only see null until free team sites are released. - Update DataLimitStatus immediately in the client when it changes, e.g. when user actions are applied or the product is changed. Right now it's only sent when the document loads. - Update row limit, grace period start, and data limit status in ActiveDoc when the product changes, i.e. the user upgrades/downgrades. - Account for data size when computing data limit status, not just row counts. See also the tasks mentioned in https://phab.getgrist.com/D3331 Test Plan: Extended FreeTeam nbrowser test, testing the 4 statuses. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3331
2 years ago
this.dataLimitStatus.set(openDocResponse.dataLimitStatus);
const gdModule = await gristDocModulePromise;
const docComm = gdModule.DocComm.create(flow, comm, openDocResponse, doc.id, this.appModel.notifier);
flow.checkIfCancelled();
docComm.changeUrlIdEmitter.addListener(async (newUrlId: string) => {
// 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});
await this.updateCurrentDoc(newUrlId, 'default');
}
});
// If a document for comparison is given, load the comparison, and provide it to the Gristdoc.
const comparison = comparisonUrlId ?
await this._api.getDocAPI(urlId).compareDoc(comparisonUrlId, { detail: true }) : undefined;
const gristDoc = gdModule.GristDoc.create(flow, this._appObj, docComm, this, openDocResponse,
this.appModel.topAppModel.plugins, {comparison});
// Move ownership of docComm to GristDoc.
gristDoc.autoDispose(flow.release(docComm));
// Move ownership of GristDoc to its final owner.
this.gristDoc.autoDispose(flow.release(gristDoc));
}
private _getDocKey(state: IGristUrlState) {
const urlId = state.doc;
const urlOpenMode = state.mode || 'default';
const compareUrlId = state.params?.compare;
const docKey = `${urlOpenMode}:${urlId}:${compareUrlId}`;
return docKey;
}
}
function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly: boolean): DomElementArg[] {
const selectBy = gristDoc.selectBy.bind(gristDoc);
return [
menuItem(
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addNewPage(val).catch(reportError),
{isNewPage: true, buttonLabel: 'Add Page'}),
menuIcon("Page"), "Add Page", testId('dp-add-new-page'),
dom.cls('disabled', isReadonly)
),
menuItem(
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addWidgetToPage(val).catch(reportError),
{isNewPage: false, selectBy}),
menuIcon("Widget"), "Add Widget to Page", testId('dp-add-widget-to-page'),
// disable for readonly doc and all special views
dom.cls('disabled', (use) => typeof use(gristDoc.activeViewId) !== 'number' || isReadonly),
),
menuItem(() => gristDoc.addEmptyTable().catch(reportError),
menuIcon("TypeTable"), "Add Empty Table", testId('dp-empty-table'),
dom.cls('disabled', isReadonly)
),
menuDivider(),
...importSources.map((importSource, i) =>
menuItem(importSource.action,
menuIcon('Import'),
importSource.label,
testId(`dp-import-option`),
dom.cls('disabled', isReadonly)
)
),
isReadonly ? menuText('You do not have edit access to this document') : null,
testId('dp-add-new-menu')
];
}
function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
const idParts = parseUrlId(doc.urlId || doc.id);
const isFork = Boolean(idParts.forkId || idParts.snapshotId);
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;
return {
...doc,
isFork,
isRecoveryMode: false, // we don't know yet, will learn when doc is opened.
userOverride: null, // ditto.
isPreFork,
isBareFork,
isReadonly: !isEditable,
idParts,
openMode,
};
}
const reconnectIntervals = [1000, 1000, 2000, 5000, 10000];
async function retryOnNetworkError<R>(flow: AsyncFlow, func: () => Promise<R>): Promise<R> {
for (let attempt = 0; ; attempt++) {
try {
return await func();
} catch (err) {
// fetch() promises that network errors are reported as TypeError. We'll accept NetworkError too.
if (err.name !== "TypeError" && err.name !== "NetworkError") {
throw err;
}
const reconnectTimeout = getReconnectTimeout(attempt, reconnectIntervals);
console.warn(`Call to ${func.name} failed, will retry in ${reconnectTimeout} ms`, err);
await delay(reconnectTimeout);
flow.checkIfCancelled();
}
}
}