2020-10-02 15:10:00 +00:00
|
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
|
|
|
import {IUndoState} from 'app/client/components/UndoStack';
|
2023-09-06 18:35:46 +00:00
|
|
|
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
|
2020-10-02 15:10:00 +00:00
|
|
|
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';
|
2020-12-14 17:42:09 +00:00
|
|
|
import {bigBasicButton} from 'app/client/ui2018/buttons';
|
2021-08-05 15:12:46 +00:00
|
|
|
import {testId} from 'app/client/ui2018/cssVars';
|
2020-10-02 15:10:00 +00:00
|
|
|
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';
|
(core) add initial support for special shares
Summary:
This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules.
It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with:
* When reading rules, if there are shares, extra rules are added.
* If there are shares, all rules are made conditional on a "ShareRef" user property.
* "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share.
There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do:
```
gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}])
```
If you look at the home db now there should be something in the `shares` table:
```
$ sqlite3 -table landing.db "select * from shares"
+----+------------------------+------------------------+--------------+---------+
| id | key | doc_id | link_id | options |
+----+------------------------+------------------------+--------------+---------+
| 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... |
+----+------------------------+------------------------+--------------+---------+
```
If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share.
E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc.
To actually share some material - useful commands:
```
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}])
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}])
```
For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options.
I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard.
Test Plan: tests added
Reviewers: dsagal, georgegevoian
Reviewed By: dsagal, georgegevoian
Subscribers: jarek, dsagal
Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
|
|
|
import {OpenDocMode, OpenDocOptions, UserOverride} from 'app/common/DocListAPI';
|
2022-05-16 17:41:12 +00:00
|
|
|
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
|
2022-06-06 16:21:26 +00:00
|
|
|
import {Product} from 'app/common/Features';
|
2023-03-22 13:48:50 +00:00
|
|
|
import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {getReconnectTimeout} from 'app/common/gutil';
|
2022-05-26 06:47:26 +00:00
|
|
|
import {canEdit, isOwner} from 'app/common/roles';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
|
|
|
|
import {Holder, Observable, subscribe} from 'grainjs';
|
2021-08-05 15:12:46 +00:00
|
|
|
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
|
2022-10-28 16:11:08 +00:00
|
|
|
import {makeT} from 'app/client/lib/localization';
|
2023-11-01 13:54:19 +00:00
|
|
|
import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// tslint:disable:no-console
|
|
|
|
|
2022-12-09 15:46:03 +00:00
|
|
|
const t = makeT('DocPageModel');
|
2022-10-28 16:11:08 +00:00
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
export interface DocInfo extends Document {
|
|
|
|
isReadonly: boolean;
|
|
|
|
isPreFork: boolean;
|
|
|
|
isFork: boolean;
|
2020-12-14 17:42:09 +00:00
|
|
|
isRecoveryMode: boolean;
|
2021-01-15 21:56:58 +00:00
|
|
|
userOverride: UserOverride|null;
|
2020-10-02 15:10:00 +00:00
|
|
|
isBareFork: boolean; // a document created without logging in, which is treated as a
|
|
|
|
// fork without an original.
|
2023-04-11 05:56:26 +00:00
|
|
|
isSnapshot: boolean;
|
2023-03-22 13:48:50 +00:00
|
|
|
isTutorialTrunk: boolean;
|
|
|
|
isTutorialFork: boolean;
|
2023-07-26 22:31:02 +00:00
|
|
|
isTemplate: boolean;
|
2020-10-02 15:10:00 +00:00
|
|
|
idParts: UrlIdParts;
|
|
|
|
openMode: OpenDocMode;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface DocPageModel {
|
|
|
|
pageType: "doc";
|
|
|
|
|
|
|
|
appModel: AppModel;
|
|
|
|
currentDoc: Observable<DocInfo|null>;
|
2022-05-16 17:41:12 +00:00
|
|
|
currentDocUsage: Observable<FilteredDocUsageSummary|null>;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2022-06-06 16:21:26 +00:00
|
|
|
/**
|
|
|
|
* Initially set to the product referenced by `currentDoc`, and updated whenever `currentDoc`
|
|
|
|
* changes, or a doc usage message is received from the server.
|
|
|
|
*/
|
|
|
|
currentProduct: Observable<Product|null>;
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// 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>;
|
2020-12-14 17:42:09 +00:00
|
|
|
isRecoveryMode: Observable<boolean>;
|
2021-01-15 21:56:58 +00:00
|
|
|
userOverride: Observable<UserOverride|null>;
|
2020-10-02 15:10:00 +00:00
|
|
|
isBareFork: Observable<boolean>;
|
2023-04-11 05:56:26 +00:00
|
|
|
isSnapshot: Observable<boolean>;
|
2023-03-22 13:48:50 +00:00
|
|
|
isTutorialTrunk: Observable<boolean>;
|
|
|
|
isTutorialFork: Observable<boolean>;
|
2023-07-26 22:31:02 +00:00
|
|
|
isTemplate: Observable<boolean>;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
importSources: ImportSource[];
|
|
|
|
|
|
|
|
undoState: Observable<IUndoState|null>; // See UndoStack for details.
|
|
|
|
|
|
|
|
gristDoc: Observable<GristDoc|null>; // Instance of GristDoc once it exists.
|
|
|
|
|
|
|
|
createLeftPane(leftPanelOpen: Observable<boolean>): DomArg;
|
|
|
|
renameDoc(value: string): Promise<void>;
|
|
|
|
updateCurrentDoc(urlId: string, openMode: OpenDocMode): Promise<Document>;
|
|
|
|
refreshCurrentDoc(doc: DocInfo): Promise<Document>;
|
2022-05-16 17:41:12 +00:00
|
|
|
updateCurrentDocUsage(docUsage: FilteredDocUsageSummary): void;
|
2022-05-18 16:05:37 +00:00
|
|
|
// Offer to open document in recovery mode, if user is owner, and report
|
|
|
|
// the error that prompted the offer. If user is not owner, just flag that
|
|
|
|
// document needs attention of an owner.
|
|
|
|
offerRecovery(err: Error): void;
|
2023-09-06 18:35:46 +00:00
|
|
|
clearUnsavedChanges(): void;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
2022-05-16 17:41:12 +00:00
|
|
|
public readonly currentDocUsage = Observable.create<FilteredDocUsageSummary|null>(this, null);
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2022-06-06 16:21:26 +00:00
|
|
|
/**
|
|
|
|
* Initially set to the product referenced by `currentDoc`, and updated whenever `currentDoc`
|
|
|
|
* changes, or a doc usage message is received from the server.
|
|
|
|
*/
|
|
|
|
public readonly currentProduct = Observable.create<Product|null>(this, null);
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
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,
|
2023-07-27 12:36:43 +00:00
|
|
|
(use, org) => getOrgNameOrGuest(org, this.appModel.currentUser));
|
2020-10-02 15:10:00 +00:00
|
|
|
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);
|
2021-04-26 21:54:09 +00:00
|
|
|
public readonly isRecoveryMode = Computed.create(this, this.currentDoc,
|
2023-07-27 12:36:43 +00:00
|
|
|
(use, doc) => doc ? doc.isRecoveryMode : false);
|
2021-01-15 21:56:58 +00:00
|
|
|
public readonly userOverride = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.userOverride : null);
|
2020-10-02 15:10:00 +00:00
|
|
|
public readonly isBareFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isBareFork : false);
|
2023-04-11 05:56:26 +00:00
|
|
|
public readonly isSnapshot = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isSnapshot : false);
|
2023-03-22 13:48:50 +00:00
|
|
|
public readonly isTutorialTrunk = Computed.create(this, this.currentDoc,
|
2023-07-18 07:24:10 +00:00
|
|
|
(use, doc) => doc ? doc.isTutorialTrunk : false);
|
2023-03-22 13:48:50 +00:00
|
|
|
public readonly isTutorialFork = Computed.create(this, this.currentDoc,
|
2023-07-18 07:24:10 +00:00
|
|
|
(use, doc) => doc ? doc.isTutorialFork : false);
|
2023-07-26 22:31:02 +00:00
|
|
|
public readonly isTemplate = Computed.create(this, this.currentDoc,
|
|
|
|
(use, doc) => doc ? doc.isTemplate : false);
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
2023-09-06 18:35:46 +00:00
|
|
|
private readonly _unsavedChangeHolder = Holder.create<UnsavedChange>(this);
|
|
|
|
|
|
|
|
private readonly _isUnsavedFork = Computed.create(this,
|
|
|
|
this.isFork,
|
|
|
|
this.isSnapshot,
|
|
|
|
this.isTutorialFork,
|
|
|
|
(use, isFork, isSnapshot, isTutorialFork) => isFork && !isSnapshot && !isTutorialFork
|
|
|
|
);
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
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;
|
2021-07-28 19:02:06 +00:00
|
|
|
const urlOpenMode = state.mode;
|
2020-12-09 13:57:35 +00:00
|
|
|
const linkParameters = state.params?.linkParameters;
|
2020-10-02 15:10:00 +00:00
|
|
|
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 {
|
(core) add initial support for special shares
Summary:
This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules.
It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with:
* When reading rules, if there are shares, extra rules are added.
* If there are shares, all rules are made conditional on a "ShareRef" user property.
* "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share.
There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do:
```
gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}])
```
If you look at the home db now there should be something in the `shares` table:
```
$ sqlite3 -table landing.db "select * from shares"
+----+------------------------+------------------------+--------------+---------+
| id | key | doc_id | link_id | options |
+----+------------------------+------------------------+--------------+---------+
| 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... |
+----+------------------------+------------------------+--------------+---------+
```
If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share.
E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc.
To actually share some material - useful commands:
```
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}])
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}])
```
For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options.
I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard.
Test Plan: tests added
Reviewers: dsagal, georgegevoian
Reviewed By: dsagal, georgegevoian
Subscribers: jarek, dsagal
Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
|
|
|
FlowRunner.create(
|
|
|
|
this._openerHolder,
|
|
|
|
(flow: AsyncFlow) => this._openDoc(flow, urlId, {
|
|
|
|
openMode: urlOpenMode,
|
|
|
|
linkParameters,
|
|
|
|
originalUrlId: state.doc,
|
|
|
|
}, state.params?.compare)
|
2021-04-26 21:54:09 +00:00
|
|
|
)
|
2023-07-27 12:36:43 +00:00
|
|
|
.resultPromise.catch(err => this._onOpenError(err));
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}));
|
2022-06-06 16:21:26 +00:00
|
|
|
|
|
|
|
this.autoDispose(this.currentOrg.addListener((org) => {
|
|
|
|
// Whenever the current doc is updated, set the current product to be the
|
|
|
|
// one referenced by the updated doc.
|
|
|
|
if (org?.billingAccount?.product.name !== this.currentProduct.get()?.name) {
|
|
|
|
this.currentProduct.set(org?.billingAccount?.product ?? null);
|
|
|
|
}
|
|
|
|
}));
|
2023-09-06 18:35:46 +00:00
|
|
|
|
|
|
|
this.autoDispose(this._isUnsavedFork.addListener((isUnsavedFork) => {
|
|
|
|
if (isUnsavedFork) {
|
|
|
|
UnsavedChange.create(this._unsavedChangeHolder);
|
|
|
|
} else {
|
|
|
|
this._unsavedChangeHolder.clear();
|
|
|
|
}
|
|
|
|
}));
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public createLeftPane(leftPanelOpen: Observable<boolean>) {
|
|
|
|
return cssLeftPanel(
|
|
|
|
dom.maybe(this.gristDoc, (activeDoc) => [
|
2023-09-08 13:05:52 +00:00
|
|
|
addNewButton({ isOpen: leftPanelOpen },
|
2020-10-02 15:10:00 +00:00
|
|
|
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}`
|
|
|
|
}),
|
2021-07-19 08:49:44 +00:00
|
|
|
testId('dp-add-new'),
|
|
|
|
dom.cls('tour-add-new'),
|
2020-10-02 15:10:00 +00:00
|
|
|
),
|
|
|
|
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);
|
2022-12-15 15:07:27 +00:00
|
|
|
const isRecoveryMode = Boolean(this.currentDoc.get()?.isRecoveryMode);
|
|
|
|
const userOverride = this.currentDoc.get()?.userOverride || null;
|
|
|
|
this.currentDoc.set({...buildDocInfo(newDoc, openMode), isRecoveryMode, userOverride});
|
2020-10-02 15:10:00 +00:00
|
|
|
return newDoc;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async refreshCurrentDoc(doc: DocInfo) {
|
|
|
|
return this.updateCurrentDoc(doc.urlId || doc.id, doc.openMode);
|
|
|
|
}
|
|
|
|
|
2022-05-16 17:41:12 +00:00
|
|
|
public updateCurrentDocUsage(docUsage: FilteredDocUsageSummary) {
|
|
|
|
this.currentDocUsage.set(docUsage);
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// Replace the URL without reloading the doc.
|
2023-03-22 13:48:50 +00:00
|
|
|
public updateUrlNoReload(
|
|
|
|
urlId: string,
|
|
|
|
urlOpenMode: OpenDocMode,
|
2023-04-18 02:23:12 +00:00
|
|
|
options: {removeSlug?: boolean, replaceUrl?: boolean} = {}
|
2023-03-22 13:48:50 +00:00
|
|
|
) {
|
2023-04-18 02:23:12 +00:00
|
|
|
const {removeSlug = false, replaceUrl = true} = options;
|
2020-10-02 15:10:00 +00:00
|
|
|
const state = urlState().state.get();
|
2023-03-22 13:48:50 +00:00
|
|
|
const nextState = {
|
|
|
|
...state,
|
|
|
|
doc: urlId,
|
|
|
|
...(removeSlug ? {slug: undefined} : undefined),
|
|
|
|
mode: urlOpenMode === 'default' ? undefined : urlOpenMode,
|
|
|
|
};
|
2020-10-02 15:10:00 +00:00
|
|
|
// We preemptively update _openerDocKey so that the URL update doesn't trigger a reload.
|
|
|
|
this._openerDocKey = this._getDocKey(nextState);
|
2023-03-22 13:48:50 +00:00
|
|
|
return urlState().pushUrl(nextState, {avoidReload: true, replace: replaceUrl});
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
2022-05-18 16:05:37 +00:00
|
|
|
public offerRecovery(err: Error) {
|
|
|
|
const isDenied = (err as any).code === 'ACL_DENY';
|
2022-05-26 06:47:26 +00:00
|
|
|
const isDocOwner = isOwner(this.currentDoc.get());
|
2020-10-02 15:10:00 +00:00
|
|
|
confirmModal(
|
2022-12-06 13:23:59 +00:00
|
|
|
t("Error accessing document"),
|
2022-10-28 16:11:08 +00:00
|
|
|
t("Reload"),
|
2020-10-02 15:10:00 +00:00
|
|
|
async () => window.location.reload(true),
|
2023-01-03 16:45:14 +00:00
|
|
|
{
|
2023-04-20 13:07:45 +00:00
|
|
|
explanation: (
|
|
|
|
isDocOwner
|
2023-07-16 16:52:13 +00:00
|
|
|
? t("You can try reloading the document, or using recovery mode. \
|
2023-07-27 12:36:43 +00:00
|
|
|
Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. \
|
2023-07-16 16:52:13 +00:00
|
|
|
It also disables formulas. [{{error}}]", {error: err.message})
|
2023-04-20 13:07:45 +00:00
|
|
|
: isDenied
|
|
|
|
? t('Sorry, access to this document has been denied. [{{error}}]', {error: err.message})
|
2024-01-03 21:38:51 +00:00
|
|
|
: t("Please reload the document and if the error persist, "+
|
|
|
|
"contact the document owners to attempt a document recovery. [{{error}}]", {error: err.message})
|
2023-04-20 13:07:45 +00:00
|
|
|
),
|
2023-01-03 16:45:14 +00:00
|
|
|
hideCancel: true,
|
|
|
|
extraButtons: !(isDocOwner && !isDenied) ? null : bigBasicButton(
|
|
|
|
t("Enter recovery mode"),
|
|
|
|
dom.on('click', async () => {
|
|
|
|
await this._api.getDocAPI(this.currentDocId.get()!).recover(true);
|
|
|
|
window.location.reload(true);
|
|
|
|
}),
|
|
|
|
testId('modal-recovery-mode')
|
|
|
|
)
|
2020-12-14 17:42:09 +00:00
|
|
|
},
|
2020-10-02 15:10:00 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-09-06 18:35:46 +00:00
|
|
|
public clearUnsavedChanges(): void {
|
|
|
|
this._unsavedChangeHolder.clear();
|
|
|
|
}
|
|
|
|
|
2022-05-18 16:05:37 +00:00
|
|
|
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);
|
|
|
|
this.offerRecovery(err);
|
|
|
|
}
|
|
|
|
|
(core) add initial support for special shares
Summary:
This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules.
It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with:
* When reading rules, if there are shares, extra rules are added.
* If there are shares, all rules are made conditional on a "ShareRef" user property.
* "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share.
There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do:
```
gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}])
```
If you look at the home db now there should be something in the `shares` table:
```
$ sqlite3 -table landing.db "select * from shares"
+----+------------------------+------------------------+--------------+---------+
| id | key | doc_id | link_id | options |
+----+------------------------+------------------------+--------------+---------+
| 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... |
+----+------------------------+------------------------+--------------+---------+
```
If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share.
E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc.
To actually share some material - useful commands:
```
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}])
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}])
```
For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options.
I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard.
Test Plan: tests added
Reviewers: dsagal, georgegevoian
Reviewed By: dsagal, georgegevoian
Subscribers: jarek, dsagal
Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
|
|
|
private async _openDoc(flow: AsyncFlow, urlId: string, options: OpenDocOptions,
|
|
|
|
comparisonUrlId: string | undefined): Promise<void> {
|
|
|
|
const {openMode: urlOpenMode, linkParameters} = options;
|
2020-10-02 15:10:00 +00:00
|
|
|
console.log(`DocPageModel _openDoc starting for ${urlId} (mode ${urlOpenMode})` +
|
2023-07-27 12:36:43 +00:00
|
|
|
(comparisonUrlId ? ` (compare ${comparisonUrlId})` : ''));
|
2020-10-02 15:10:00 +00:00
|
|
|
const gristDocModulePromise = loadGristDoc();
|
|
|
|
|
|
|
|
const docResponse = await retryOnNetworkError(flow, getDoc.bind(null, this._api, urlId));
|
|
|
|
flow.checkIfCancelled();
|
|
|
|
|
2023-03-22 13:48:50 +00:00
|
|
|
let doc = buildDocInfo(docResponse, urlOpenMode);
|
|
|
|
if (doc.isTutorialTrunk) {
|
|
|
|
// We're loading a tutorial, so we need to prepare a URL to a fork of the
|
|
|
|
// tutorial. The URL will either be to an existing fork, or a new fork if this
|
|
|
|
// is the first time the user is opening the tutorial.
|
|
|
|
const fork = doc.forks?.[0];
|
|
|
|
let forkUrlId: string | undefined;
|
|
|
|
if (fork) {
|
|
|
|
// If a fork of this tutorial already exists, prepare to navigate to it.
|
|
|
|
forkUrlId = buildUrlId({
|
|
|
|
trunkId: doc.urlId || doc.id,
|
|
|
|
forkId: fork.id,
|
|
|
|
forkUserId: this.appModel.currentValidUser!.id
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
// Otherwise, create a new fork and prepare to navigate to it.
|
|
|
|
const forkResult = await this._api.getDocAPI(doc.id).fork();
|
|
|
|
flow.checkIfCancelled();
|
|
|
|
forkUrlId = forkResult.urlId;
|
|
|
|
}
|
|
|
|
// Remove the slug from the fork URL - they don't work with slugs.
|
|
|
|
await this.updateUrlNoReload(forkUrlId, 'default', {removeSlug: true});
|
|
|
|
await this.updateCurrentDoc(forkUrlId, 'default');
|
|
|
|
flow.checkIfCancelled();
|
|
|
|
doc = this.currentDoc.get()!;
|
|
|
|
} else {
|
|
|
|
if (doc.urlId && doc.urlId !== urlId) {
|
|
|
|
// Replace the URL to reflect the canonical urlId.
|
|
|
|
await this.updateUrlNoReload(doc.urlId, doc.openMode);
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2023-11-01 13:54:19 +00:00
|
|
|
if (doc.isTemplate) {
|
|
|
|
logTelemetryEvent('openedTemplate', {
|
|
|
|
full: {
|
|
|
|
templateId: parseUrlId(doc.urlId || doc.id).trunkId,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2023-03-22 13:48:50 +00:00
|
|
|
this.currentDoc.set(doc);
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// 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));
|
|
|
|
|
(core) add initial support for special shares
Summary:
This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules.
It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with:
* When reading rules, if there are shares, extra rules are added.
* If there are shares, all rules are made conditional on a "ShareRef" user property.
* "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share.
There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do:
```
gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}])
```
If you look at the home db now there should be something in the `shares` table:
```
$ sqlite3 -table landing.db "select * from shares"
+----+------------------------+------------------------+--------------+---------+
| id | key | doc_id | link_id | options |
+----+------------------------+------------------------+--------------+---------+
| 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... |
+----+------------------------+------------------------+--------------+---------+
```
If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share.
E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc.
To actually share some material - useful commands:
```
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}])
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}])
```
For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options.
I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard.
Test Plan: tests added
Reviewers: dsagal, georgegevoian
Reviewed By: dsagal, georgegevoian
Subscribers: jarek, dsagal
Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
|
|
|
const openDocResponse = await comm.openDoc(doc.id, {
|
|
|
|
openMode: doc.openMode,
|
|
|
|
linkParameters,
|
|
|
|
originalUrlId: options.originalUrlId,
|
|
|
|
});
|
2021-01-15 21:56:58 +00:00
|
|
|
if (openDocResponse.recoveryMode || openDocResponse.userOverride) {
|
|
|
|
doc.isRecoveryMode = Boolean(openDocResponse.recoveryMode);
|
|
|
|
doc.userOverride = openDocResponse.userOverride || null;
|
2020-12-14 17:42:09 +00:00
|
|
|
this.currentDoc.set({...doc});
|
|
|
|
}
|
2022-05-03 05:20:31 +00:00
|
|
|
if (openDocResponse.docUsage) {
|
2022-05-16 17:41:12 +00:00
|
|
|
this.updateCurrentDocUsage(openDocResponse.docUsage);
|
2022-05-03 05:20:31 +00:00
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
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) {
|
2023-03-22 13:48:50 +00:00
|
|
|
// Remove the slug from the fork URL - they don't work with slugs.
|
|
|
|
await this.updateUrlNoReload(newUrlId, 'default', {removeSlug: true, replaceUrl: false});
|
2020-10-02 15:10:00 +00:00
|
|
|
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;
|
|
|
|
|
2023-06-02 11:25:14 +00:00
|
|
|
const gristDoc = gdModule.GristDoc.create(flow, this._appObj, this.appModel, docComm, this, openDocResponse,
|
2023-07-27 12:36:43 +00:00
|
|
|
this.appModel.topAppModel.plugins, {comparison});
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// 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(
|
2022-12-20 02:06:39 +00:00
|
|
|
(elem) => openPageWidgetPicker(elem, gristDoc, (val) => gristDoc.addNewPage(val).catch(reportError),
|
2023-07-27 12:36:43 +00:00
|
|
|
{isNewPage: true, buttonLabel: 'Add Page'}),
|
2022-12-06 13:23:59 +00:00
|
|
|
menuIcon("Page"), t("Add Page"), testId('dp-add-new-page'),
|
2020-10-02 15:10:00 +00:00
|
|
|
dom.cls('disabled', isReadonly)
|
|
|
|
),
|
|
|
|
menuItem(
|
2022-12-20 02:06:39 +00:00
|
|
|
(elem) => openPageWidgetPicker(elem, gristDoc, (val) => gristDoc.addWidgetToPage(val).catch(reportError),
|
2023-07-27 12:36:43 +00:00
|
|
|
{isNewPage: false, selectBy}),
|
2022-12-06 13:23:59 +00:00
|
|
|
menuIcon("Widget"), t("Add Widget to Page"), testId('dp-add-widget-to-page'),
|
2021-08-09 09:41:27 +00:00
|
|
|
// disable for readonly doc and all special views
|
|
|
|
dom.cls('disabled', (use) => typeof use(gristDoc.activeViewId) !== 'number' || isReadonly),
|
2020-10-02 15:10:00 +00:00
|
|
|
),
|
2021-04-26 21:54:09 +00:00
|
|
|
menuItem(() => gristDoc.addEmptyTable().catch(reportError),
|
2022-12-06 13:23:59 +00:00
|
|
|
menuIcon("TypeTable"), t("Add Empty Table"), testId('dp-empty-table'),
|
2021-04-26 21:54:09 +00:00
|
|
|
dom.cls('disabled', isReadonly)
|
|
|
|
),
|
2020-10-02 15:10:00 +00:00
|
|
|
menuDivider(),
|
|
|
|
...importSources.map((importSource, i) =>
|
|
|
|
menuItem(importSource.action,
|
|
|
|
menuIcon('Import'),
|
|
|
|
importSource.label,
|
|
|
|
testId(`dp-import-option`),
|
|
|
|
dom.cls('disabled', isReadonly)
|
|
|
|
)
|
|
|
|
),
|
2022-12-06 13:23:59 +00:00
|
|
|
isReadonly ? menuText(t("You do not have edit access to this document")) : null,
|
2020-10-02 15:10:00 +00:00
|
|
|
testId('dp-add-new-menu')
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
2021-07-28 19:02:06 +00:00
|
|
|
function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
|
2020-10-02 15:10:00 +00:00
|
|
|
const idParts = parseUrlId(doc.urlId || doc.id);
|
|
|
|
const isFork = Boolean(idParts.forkId || idParts.snapshotId);
|
2023-07-26 22:31:02 +00:00
|
|
|
const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE;
|
|
|
|
const isSnapshot = Boolean(idParts.snapshotId);
|
|
|
|
const isTutorial = doc.type === 'tutorial';
|
|
|
|
const isTutorialTrunk = isTutorial && !isFork && mode !== 'default';
|
|
|
|
const isTutorialFork = isTutorial && isFork;
|
2021-07-28 19:02:06 +00:00
|
|
|
|
|
|
|
let openMode = mode;
|
|
|
|
if (!openMode) {
|
2023-07-26 22:31:02 +00:00
|
|
|
if (isFork || isTutorialTrunk || isTutorialFork) {
|
|
|
|
// Tutorials (if no explicit /m/default mode is set) automatically get or
|
|
|
|
// create a fork on load, which then behaves as a document that is in default
|
|
|
|
// mode. Since the document's 'openMode' has no effect, don't bother trying
|
|
|
|
// to set it here, as it'll potentially be confusing for other code reading it.
|
2021-07-28 19:02:06 +00:00
|
|
|
openMode = 'default';
|
2023-07-26 22:31:02 +00:00
|
|
|
} else if (!isFork && doc.type === 'template') {
|
|
|
|
// Templates should always open in fork mode by default.
|
|
|
|
openMode = 'fork';
|
2021-07-28 19:02:06 +00:00
|
|
|
} else {
|
|
|
|
// Try to use the document's 'openMode' if it's set.
|
|
|
|
openMode = doc.options?.openMode ?? 'default';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-26 22:31:02 +00:00
|
|
|
const isPreFork = openMode === 'fork';
|
|
|
|
const isTemplate = doc.type === 'template' && (isFork || isPreFork);
|
2023-04-11 05:56:26 +00:00
|
|
|
const isEditable = !isSnapshot && (canEdit(doc.access) || isPreFork);
|
2020-10-02 15:10:00 +00:00
|
|
|
return {
|
|
|
|
...doc,
|
|
|
|
isFork,
|
2020-12-14 17:42:09 +00:00
|
|
|
isRecoveryMode: false, // we don't know yet, will learn when doc is opened.
|
2021-01-15 21:56:58 +00:00
|
|
|
userOverride: null, // ditto.
|
2020-10-02 15:10:00 +00:00
|
|
|
isPreFork,
|
|
|
|
isBareFork,
|
2023-04-11 05:56:26 +00:00
|
|
|
isSnapshot,
|
2023-03-22 13:48:50 +00:00
|
|
|
isTutorialTrunk,
|
|
|
|
isTutorialFork,
|
2023-07-26 22:31:02 +00:00
|
|
|
isTemplate,
|
2020-10-02 15:10:00 +00:00
|
|
|
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();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|