2020-10-02 15:10:00 +00:00
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' ;
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' ;
2021-01-15 21:56:58 +00:00
import { OpenDocMode , 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' ;
2020-10-02 15:10:00 +00:00
import { IGristUrlState , parseUrlId , UrlIdParts } from 'app/common/gristUrls' ;
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' ;
2020-10-02 15:10:00 +00:00
// tslint:disable:no-console
2022-10-28 16:11:08 +00:00
const t = makeT ( 'models.DocPageModel' ) ;
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.
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 > ;
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 ;
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 ,
( 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 ) ;
2021-04-26 21:54:09 +00:00
public readonly isRecoveryMode = Computed . create ( this , this . currentDoc ,
( 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 ) ;
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 ) ;
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 {
2021-04-26 21:54:09 +00:00
FlowRunner . create ( this . _openerHolder ,
( flow : AsyncFlow ) = > this . _openDoc ( flow , urlId , urlOpenMode , state . params ? . compare , linkParameters )
)
2020-10-02 15:10:00 +00:00
. resultPromise . catch ( err = > this . _onOpenError ( err ) ) ;
}
}
} ) ) ;
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 ) ;
}
} ) ) ;
2020-10-02 15:10:00 +00:00
}
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 } `
} ) ,
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 ) ;
this . currentDoc . set ( buildDocInfo ( newDoc , openMode ) ) ;
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.
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 } ) ;
}
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 ) ,
2022-12-06 13:23:59 +00:00
isDocOwner ? t ( "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. It also disables formulas. [{{error}}]" , { error : err.message } ) :
2022-12-06 15:36:14 +00:00
isDenied ? t ( 'Sorry, access to this document has been denied. [{{error}}]' , { error : err.message } ) :
t ( "Document owners can attempt to recover the document. [{{error}}]" , { error : err.message } ) ,
2020-12-14 17:42:09 +00:00
{ hideCancel : true ,
2022-12-06 13:23:59 +00:00
extraButtons : ( isDocOwner && ! isDenied ) ? bigBasicButton ( t ( "Enter recovery mode" ) , dom . on ( 'click' , async ( ) = > {
2020-12-14 17:42:09 +00:00
await this . _api . getDocAPI ( this . currentDocId . get ( ) ! ) . recover ( true ) ;
window . location . reload ( true ) ;
} ) , testId ( 'modal-recovery-mode' ) ) : null ,
} ,
2020-10-02 15:10:00 +00:00
) ;
}
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 ) ;
}
2021-07-28 19:02:06 +00:00
private async _openDoc ( flow : AsyncFlow , urlId : string , urlOpenMode : OpenDocMode | undefined ,
2020-12-09 13:57:35 +00:00
comparisonUrlId : string | undefined ,
linkParameters : Record < string , string > | undefined ) : Promise < void > {
2020-10-02 15:10:00 +00:00
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 ) ) ;
2020-12-09 13:57:35 +00:00
const openDocResponse = await comm . openDoc ( doc . id , doc . openMode , linkParameters ) ;
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 ) {
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 ,
2021-08-05 15:12:46 +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 ) ,
2020-10-02 15:10:00 +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 ) ,
2020-10-02 15:10:00 +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 ) ;
2021-07-28 19:02:06 +00:00
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' ;
}
}
2020-10-02 15:10:00 +00:00
const isPreFork = ( openMode === 'fork' ) ;
const isBareFork = isFork && idParts . trunkId === NEW_DOCUMENT_CODE ;
const isEditable = canEdit ( doc . access ) || isPreFork ;
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 ,
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 ( ) ;
}
}
}