From 4ca47878cae322b82122f0673eb9a397dc3333f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Thu, 5 Aug 2021 17:12:46 +0200 Subject: [PATCH] (core) Adding import from google drive to the home screen Summary: Importing from google drive from home screen (also for anonymous users) Test Plan: Browser tests Reviewers: dsagal, paulfitz Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2943 --- app/client/components/GristDoc.ts | 6 +- app/client/components/Importer.ts | 108 ++++-------------------- app/client/components/PluginScreen.ts | 115 ++++++++++++++++++++++++++ app/client/lib/HomePluginManager.ts | 57 +++++++++++++ app/client/models/AppModel.ts | 30 +++++++ app/client/models/DocPageModel.ts | 6 +- app/client/models/HomeModel.ts | 17 +++- app/client/ui/App.ts | 28 +------ app/client/ui/AppUI.ts | 6 +- app/client/ui/HomeImports.ts | 51 ++++++++++++ app/client/ui/HomeLeftPane.ts | 33 +++++++- app/common/DocListAPI.ts | 2 - app/common/gristUrls.ts | 4 + app/gen-server/ApiServer.ts | 2 +- app/plugin/PluginManifest-ti.ts | 1 + app/plugin/PluginManifest.ts | 7 ++ app/server/lib/AppEndpoint.ts | 9 +- app/server/lib/DocManager.ts | 1 - app/server/lib/DocPluginManager.ts | 2 +- app/server/lib/FlexServer.ts | 3 +- app/server/mergedServerMain.ts | 2 +- 21 files changed, 348 insertions(+), 142 deletions(-) create mode 100644 app/client/components/PluginScreen.ts create mode 100644 app/client/lib/HomePluginManager.ts diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index f4d212f9..cb3bc3d3 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -47,6 +47,7 @@ import {OpenLocalDocResult} from 'app/common/DocListAPI'; import {HashLink, IDocPage} from 'app/common/gristUrls'; import {RecalcWhen} from 'app/common/gristTypes'; import {encodeQueryParams, undef, waitObs} from 'app/common/gutil'; +import {LocalPlugin} from "app/common/plugin"; import {StringUnion} from 'app/common/StringUnion'; import {TableData} from 'app/common/TableData'; import {DocStateComparison} from 'app/common/UserAPI'; @@ -143,6 +144,7 @@ export class GristDoc extends DisposableWithEvents { public readonly docComm: DocComm, public readonly docPageModel: DocPageModel, openDocResponse: OpenLocalDocResult, + plugins: LocalPlugin[], options: { comparison?: DocStateComparison // initial comparison with another document } = {} @@ -152,8 +154,8 @@ export class GristDoc extends DisposableWithEvents { this.docData = new DocData(this.docComm, openDocResponse.doc); this.docModel = new DocModel(this.docData); this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm); - this.docPluginManager = new DocPluginManager(openDocResponse.plugins, app.getUntrustedContentOrigin(), - this.docComm, app.clientScope); + this.docPluginManager = new DocPluginManager(plugins, + app.topAppModel.getUntrustedContentOrigin(), this.docComm, app.clientScope); // Maintain the MetaRowModel for the global document info, including docId and peers. this.docInfo = this.docModel.docInfo.getRowModel(1); diff --git a/app/client/components/Importer.ts b/app/client/components/Importer.ts index 82d7ddb7..83814917 100644 --- a/app/client/components/Importer.ts +++ b/app/client/components/Importer.ts @@ -6,6 +6,7 @@ import {GristDoc} from "app/client/components/GristDoc"; import {buildParseOptionsForm, ParseOptionValues} from 'app/client/components/ParseOptions'; +import {PluginScreen} from "app/client/components/PluginScreen"; import {ImportSourceElement} from 'app/client/lib/ImportSourceElement'; import {fetchURL, isDriveUrl, selectFiles, uploadFiles} from 'app/client/lib/uploads'; import {reportError} from 'app/client/models/AppModel'; @@ -14,15 +15,13 @@ import {openFilePicker} from "app/client/ui/FileDialog"; import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; import {colors, testId, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; -import {loadingSpinner} from 'app/client/ui2018/loaders'; import {IOptionFull, linkSelect} from 'app/client/ui2018/menus'; -import {cssModalButtons, cssModalTitle, IModalControl, modal} from 'app/client/ui2018/modals'; +import {cssModalButtons, cssModalTitle} from 'app/client/ui2018/modals'; import {DataSourceTransformed, ImportResult, ImportTableResult} from "app/common/ActiveDocAPI"; import {TransformColumn, TransformRule, TransformRuleMap} from "app/common/ActiveDocAPI"; import {byteString} from "app/common/gutil"; import {UploadResult} from 'app/common/uploads'; import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI'; -import {RenderTarget} from 'app/plugin/RenderOptions'; import {Computed, Disposable, dom, DomContents, IDisposable, Observable, styled} from 'grainjs'; // Special values for import destinations; null means "new table". @@ -118,9 +117,8 @@ export class Importer extends Disposable { private _docComm = this._gristDoc.docComm; private _uploadResult?: UploadResult; - private _openModalCtl: IModalControl|null = null; - private _importerContent = Observable.create(this, null); + private _screen: PluginScreen; private _parseOptions = Observable.create(this, {}); private _sourceInfoArray = Observable.create(this, []); private _sourceInfoSelected = Observable.create(this, null); @@ -143,6 +141,7 @@ export class Importer extends Disposable { constructor(private _gristDoc: GristDoc, private _importSourceElem: ImportSourceElement|null, private _createPreview: CreatePreviewFunc) { super(); + this._screen = PluginScreen.create(this, _importSourceElem?.importSource.label || "Import from file"); } /* @@ -158,22 +157,10 @@ export class Importer extends Disposable { multiple: true, sizeLimit: 'import'}); } else { const plugin = this._importSourceElem.plugin; - - // registers a render target for plugin to render inline. - const handle: RenderTarget = plugin.addRenderTarget((el, opt = {}) => { - el.style.width = "100%"; - el.style.height = opt.height || "200px"; - this._showImportDialog(); - this._renderPlugin(el); - }); - + const handle = this._screen.renderPlugin(plugin); const importSource = await this._importSourceElem.importSourceStub.getImportSource(handle); plugin.removeRenderTarget(handle); - - if (!this._openModalCtl) { - this._showImportDialog(); - } - this._renderSpinner(); + this._screen.renderSpinner(); if (importSource) { // If data has been picked, upload it. @@ -202,10 +189,7 @@ export class Importer extends Disposable { await this._cancelImport(); throw err; } - if (!this._openModalCtl) { - this._showImportDialog(); - } - this._renderError(err.message); + this._screen.renderError(err.message); return; } @@ -271,8 +255,7 @@ export class Importer extends Disposable { } private async _reImport(upload: UploadResult) { - this._renderSpinner(); - this._showImportDialog(); + this._screen.renderSpinner(); try { const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 100}; const importResult: ImportResult = await this._docComm.importFiles( @@ -300,12 +283,12 @@ export class Importer extends Disposable { } catch (e) { console.warn("Import failed", e); - this._renderError(e.message); + this._screen.renderError(e.message); } } private async _finishImport(upload: UploadResult) { - this._renderSpinner(); + this._screen.renderSpinner(); const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 0}; const importResult: ImportResult = await this._docComm.finishImportFiles( this._getTransformedDataSource(upload), parseOptions, this._getHiddenTableIds()); @@ -314,7 +297,7 @@ export class Importer extends Disposable { const tableRowModel = this._gristDoc.docModel.dataTables[importResult.tables[0].hiddenTableId].tableMetaRow; await this._gristDoc.openDocPage(tableRowModel.primaryViewId()); } - this._openModalCtl?.close(); + this._screen.close(); this.dispose(); } @@ -323,60 +306,19 @@ export class Importer extends Disposable { await this._docComm.cancelImportFiles( this._getTransformedDataSource(this._uploadResult), this._getHiddenTableIds()); } - this._openModalCtl?.close(); + this._screen.close(); this.dispose(); } - private _showImportDialog() { - if (this._openModalCtl) { return; } - modal((ctl, owner) => { - this._openModalCtl = ctl; - return [ - cssModalOverrides.cls(''), - dom.domComputed(this._importerContent), - testId('importer-dialog'), - ]; - }, { - noClickAway: true, - noEscapeKey: true, - }); - } - private _buildModalTitle(rightElement?: DomContents) { const title = this._importSourceElem ? this._importSourceElem.importSource.label : 'Import from file'; return cssModalHeader(cssModalTitle(title), rightElement); } - // The importer state showing just a spinner, when the user has to wait. We don't even let the - // user cancel it, because the cleanup can only happen properly once the wait completes. - private _renderSpinner() { - this._importerContent.set([this._buildModalTitle(), cssSpinner(loadingSpinner())]); - } - - // The importer state showing the inline element from the plugin (e.g. to enter URL in case of - // import-from-url). - private _renderPlugin(inlineElement: HTMLElement) { - this._importerContent.set([this._buildModalTitle(), inlineElement]); - } - - // The importer state showing just an error. - private _renderError(message: string) { - this._importerContent.set([ - this._buildModalTitle(), - cssModalBody('Import failed: ', message, testId('importer-error')), - cssModalButtons( - bigBasicButton('Close', - dom.on('click', () => this._cancelImport()), - testId('modal-cancel'), - ), - ), - ]); - } - // The importer state showing import in progress, with a list of tables, and a preview. private _renderMain(upload: UploadResult) { const schema = this._parseOptions.get().SCHEMA; - this._importerContent.set([ + this._screen.render([ this._buildModalTitle( schema ? cssActionLink(cssLinkIcon('Settings'), 'Import options', testId('importer-options-link'), @@ -424,7 +366,7 @@ export class Importer extends Disposable { // The importer state showing parse options that may be changed. private _renderParseOptions(schema: ParseOptionSchema[], upload: UploadResult) { - this._importerContent.set([ + this._screen.render([ this._buildModalTitle(), dom.create(buildParseOptionsForm, schema, this._parseOptions.get() as ParseOptionValues, (p: ParseOptions) => { @@ -466,28 +408,6 @@ const cssLinkIcon = styled(icon, ` margin-right: 4px; `); -const cssModalOverrides = styled('div', ` - max-height: calc(100% - 32px); - display: flex; - flex-direction: column; - & > .${cssModalButtons.className} { - margin-top: 16px; - } -`); - -const cssModalBody = styled('div', ` - padding: 16px 0; - overflow-y: auto; - max-width: 470px; -`); - -const cssSpinner = styled('div', ` - display: flex; - align-items: center; - height: 80px; - margin: auto; -`); - const cssModalHeader = styled('div', ` display: flex; align-items: center; diff --git a/app/client/components/PluginScreen.ts b/app/client/components/PluginScreen.ts new file mode 100644 index 00000000..9e08d1be --- /dev/null +++ b/app/client/components/PluginScreen.ts @@ -0,0 +1,115 @@ +import { bigBasicButton } from 'app/client/ui2018/buttons'; +import { testId } from 'app/client/ui2018/cssVars'; +import { loadingSpinner } from 'app/client/ui2018/loaders'; +import { cssModalButtons, cssModalTitle, IModalControl, modal } from 'app/client/ui2018/modals'; +import { PluginInstance } from 'app/common/PluginInstance'; +import { RenderTarget } from 'app/plugin/RenderOptions'; +import { Disposable, dom, DomContents, Observable, styled } from 'grainjs'; + +/** + * Helper for showing plugin components during imports. + */ +export class PluginScreen extends Disposable { + private _openModalCtl: IModalControl | null = null; + private _importerContent = Observable.create(this, null); + + constructor(private _title: string) { + super(); + } + + // The importer state showing the inline element from the plugin (e.g. to enter URL in case of + // import-from-url). + public renderContent(inlineElement: HTMLElement) { + this.render([this._buildModalTitle(), inlineElement]); + } + + // registers a render target for plugin to render inline. + public renderPlugin(plugin: PluginInstance): RenderTarget { + const handle: RenderTarget = plugin.addRenderTarget((el, opt = {}) => { + el.style.width = "100%"; + el.style.height = opt.height || "200px"; + this.renderContent(el); + }); + return handle; + } + + public render(content: DomContents) { + this.showImportDialog(); + this._importerContent.set(content); + } + + // The importer state showing just an error. + public renderError(message: string) { + this.render([ + this._buildModalTitle(), + cssModalBody('Import failed: ', message, testId('importer-error')), + cssModalButtons( + bigBasicButton('Close', + dom.on('click', () => this.close()), + testId('modal-cancel'))), + ]); + } + + // The importer state showing just a spinner, when the user has to wait. We don't even let the + // user cancel it, because the cleanup can only happen properly once the wait completes. + public renderSpinner() { + this.render([this._buildModalTitle(), cssSpinner(loadingSpinner())]); + } + + public close() { + this._openModalCtl?.close(); + this._openModalCtl = null; + } + + public showImportDialog() { + if (this._openModalCtl) { return; } + modal((ctl) => { + this._openModalCtl = ctl; + return [ + cssModalOverrides.cls(''), + dom.domComputed(this._importerContent), + testId('importer-dialog'), + ]; + }, { + noClickAway: true, + noEscapeKey: true, + }); + } + + private _buildModalTitle(rightElement?: DomContents) { + return cssModalHeader(cssModalTitle(this._title), rightElement); + } +} + + +const cssModalOverrides = styled('div', ` + max-height: calc(100% - 32px); + display: flex; + flex-direction: column; + & > .${cssModalButtons.className} { + margin-top: 16px; + } +`); + +const cssModalBody = styled('div', ` + padding: 16px 0; + overflow-y: auto; + max-width: 470px; +`); + +const cssModalHeader = styled('div', ` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + & > .${cssModalTitle.className} { + margin-bottom: 0px; + } +`); + +const cssSpinner = styled('div', ` + display: flex; + align-items: center; + height: 80px; + margin: auto; +`); diff --git a/app/client/lib/HomePluginManager.ts b/app/client/lib/HomePluginManager.ts new file mode 100644 index 00000000..41f3f753 --- /dev/null +++ b/app/client/lib/HomePluginManager.ts @@ -0,0 +1,57 @@ +import {ClientScope} from 'app/client/components/ClientScope'; +import {SafeBrowser} from 'app/client/lib/SafeBrowser'; +import {LocalPlugin} from 'app/common/plugin'; +import {createRpcLogger, PluginInstance} from 'app/common/PluginInstance'; + +/** + * Home plugins are all plugins that contributes to a general Grist management tasks. + * They operate on Grist as a whole, without current document context. + * TODO: currently it is used primary for importing documents on home screen and supports + * only safeBrowser components without any access to Grist. + */ +export class HomePluginManager { + + public pluginsList: PluginInstance[]; + + constructor(localPlugins: LocalPlugin[], + _untrustedContentOrigin: string, + _clientScope: ClientScope) { + this.pluginsList = []; + for (const plugin of localPlugins) { + try { + const components = plugin.manifest.components || {}; + // Home plugins supports only safeBrowser components + if (components.safePython || components.unsafeNode) { + continue; + } + // and currently implements only safe imports + const importSources = plugin.manifest.contributions.importSources; + if (!importSources?.some(i => i.safeHome)) { + continue; + } + const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `HOME PLUGIN ${plugin.id}:`)); + const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser(pluginInstance, + _clientScope, _untrustedContentOrigin, components.safeBrowser); + if (components.safeBrowser) { + pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser); + } + const forwarder = new NotAvailableForwarder(); + // Block any calls to internal apis. + pluginInstance.rpc.registerForwarder('*', { + forwardCall: (call) => forwarder.forwardPluginRpc(plugin.id, call), + forwardMessage: (msg) => forwarder.forwardPluginRpc(plugin.id, msg), + }); + this.pluginsList.push(pluginInstance); + } catch (err) { + console.error( // tslint:disable-line:no-console + `HomePluginManager: failed to instantiate ${plugin.id}: ${err.message}`); + } + } + } +} + +class NotAvailableForwarder { + public async forwardPluginRpc(pluginId: string, msg: any) { + throw new Error("This api is not available"); + } +} diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index e5209a2f..3c2784c7 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -1,3 +1,4 @@ +import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {reportError, setErrorNotifier} from 'app/client/models/errors'; import {urlState} from 'app/client/models/gristUrlState'; import {Notifier} from 'app/client/models/NotifyModel'; @@ -5,12 +6,14 @@ import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes'; import {Features} from 'app/common/Features'; import {GristLoadConfig} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; +import {LocalPlugin} from 'app/common/plugin'; import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; import {Computed, Disposable, Observable, subscribe} from 'grainjs'; export {reportError} from 'app/client/models/errors'; export type PageType = "doc" | "home" | "billing" | "welcome"; +const G = getBrowserGlobals('document', 'window'); // TopAppModel is the part of the app model that persists across org and user switches. export interface TopAppModel { @@ -20,6 +23,7 @@ export interface TopAppModel { currentSubdomain: Observable; notifier: Notifier; + plugins: LocalPlugin[]; // Everything else gets fully rebuilt when the org/user changes. This is to ensure that // different parts of the code aren't using different users/orgs while the switch is pending. @@ -30,6 +34,11 @@ export interface TopAppModel { // Rebuilds the AppModel and consequently the AppUI, without changing the user or the org. reload(): void; + + /** + * Returns the UntrustedContentOrigin use settings. Throws if not defined. + */ + getUntrustedContentOrigin(): string; } // AppModel is specific to the currently loaded organization and active user. It gets rebuilt when @@ -59,6 +68,8 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { public readonly currentSubdomain = Computed.create(this, urlState().state, (use, s) => s.org); public readonly notifier = Notifier.create(this); public readonly appObs = Observable.create(this, null); + public readonly plugins: LocalPlugin[] = []; + private readonly _gristConfig?: GristLoadConfig; constructor( window: {gristConfig?: GristLoadConfig}, @@ -68,10 +79,12 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { setErrorNotifier(this.notifier); this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg); this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org); + this._gristConfig = window.gristConfig; // Initially, and on any change to subdomain, call initialize() to get the full Organization // and the FullUser to use for it (the user may change when switching orgs). this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize())); + this.plugins = this._gristConfig?.plugins || []; } public initialize(): void { @@ -87,6 +100,23 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { } } + public getUntrustedContentOrigin() { + if (G.window.isRunningUnderElectron) { + // when loaded within webviews it is safe to serve plugin's content from the same domain + return ""; + } + + const origin = this._gristConfig?.pluginUrl; + if (!origin) { + throw new Error("Missing untrustedContentOrigin configuration"); + } + if (origin.match(/:[0-9]+$/)) { + // Port number already specified, no need to add. + return origin; + } + return origin + ":" + G.window.location.port; + } + private async _doInitialize() { this.appObs.set(null); try { diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index f6253e70..c5634587 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -10,8 +10,8 @@ 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 {testId} from 'app/client/ui2018/cssVars'; 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'; @@ -21,8 +21,8 @@ import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls'; import {getReconnectTimeout} from 'app/common/gutil'; import {canEdit} from 'app/common/roles'; import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI'; -import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs'; import {Holder, Observable, subscribe} from 'grainjs'; +import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs'; // tslint:disable:no-console @@ -271,7 +271,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { await this._api.getDocAPI(urlId).compareDoc(comparisonUrlId, { detail: true }) : undefined; const gristDoc = gdModule.GristDoc.create(flow, this._appObj, docComm, this, openDocResponse, - {comparison}); + this.appModel.topAppModel.plugins, {comparison}); // Move ownership of docComm to GristDoc. gristDoc.autoDispose(flow.release(docComm)); diff --git a/app/client/models/HomeModel.ts b/app/client/models/HomeModel.ts index 66524c46..1e3242f1 100644 --- a/app/client/models/HomeModel.ts +++ b/app/client/models/HomeModel.ts @@ -1,4 +1,7 @@ +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 {UserError} from 'app/client/models/errors'; @@ -10,9 +13,9 @@ import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs'; import * as roles from 'app/common/roles'; import {Document, Workspace} from 'app/common/UserAPI'; import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; +import * as moment from 'moment'; import flatten = require('lodash/flatten'); import sortBy = require('lodash/sortBy'); -import * as moment from 'moment'; const DELAY_BEFORE_SPINNER_MS = 500; @@ -62,6 +65,7 @@ export interface HomeModel { currentSort: Observable; currentView: Observable; + importSources: Observable; // 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. @@ -96,6 +100,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings public readonly singleWorkspace = Observable.create(this, true); public readonly trashWorkspaces = Observable.create(this, []); public readonly templateWorkspaces = Observable.create(this, []); + public readonly importSources = Observable.create(this, []); // Get the workspace details for the workspace with id of currentWSId. public readonly currentWS = Computed.create(this, (use) => @@ -133,7 +138,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings private _userOrgPrefs = Observable.create(this, this._app.currentOrg?.userOrgPrefs); - constructor(private _app: AppModel) { + constructor(private _app: AppModel, clientScope: ClientScope) { super(); if (!this.app.currentValidUser) { @@ -155,6 +160,14 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings 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); } // Accessor for the AppModel containing this HomeModel. diff --git a/app/client/ui/App.ts b/app/client/ui/App.ts index 94065115..6a8282f7 100644 --- a/app/client/ui/App.ts +++ b/app/client/ui/App.ts @@ -63,7 +63,7 @@ export class App extends DisposableWithEvents { this.autoDispose(Clipboard.create(this)); } else { // On mobile, we do not want to keep focus on a special textarea (which would cause unwanted - // scrolling and showing of mobile keyboard). But we still rely on 'cliboard_focus' and + // scrolling and showing of mobile keyboard). But we still rely on 'clipboard_focus' and // 'clipboard_blur' events to know when the "app" has a focus (rather than a particular // input), by making document.body focusable and using a FocusLayer with it as the default. document.body.setAttribute('tabindex', '-1'); @@ -172,7 +172,7 @@ export class App extends DisposableWithEvents { this.autoDispose(createAppUI(this.topAppModel, this)); } - // We want to test erors from Selenium, but errors we can trigger using driver.executeScript() + // We want to test errors from Selenium, but errors we can trigger using driver.executeScript() // will be impossible for the application to report properly (they seem to be considered not of // "same-origin"). So this silly callback is for tests to generate a fake error. public testTriggerError(msg: string) { throw new Error(msg); } @@ -184,7 +184,7 @@ export class App extends DisposableWithEvents { // When called as a dom method, adds the "newui" class when ?newui=1 is set. For example // dom('div.some-old-class', this.app.addNewUIClass(), ...) - // Then you may overridde newui styles in CSS by using selectors like: + // Then you may override newui styles in CSS by using selectors like: // .some-old-class.newui { ... } public addNewUIClass(): DomElementMethod { return (elem) => { if (this.useNewUI) { elem.classList.add('newui'); } }; @@ -206,28 +206,6 @@ export class App extends DisposableWithEvents { return true; } - /** - * Returns the UntrustedContentOrigin use settings. Throws if not defined. The configured - * UntrustedContentOrign should not include the port, it is defined at runtime. - */ - public getUntrustedContentOrigin() { - - if (G.window.isRunningUnderElectron) { - // when loaded within webviews it is safe to serve plugin's content from the same domain - return ""; - } - - const origin = G.window.gristConfig.pluginUrl; - if (!origin) { - throw new Error("Missing untrustedContentOrigin configuration"); - } - if (origin.match(/:[0-9]+$/)) { - // Port number already specified, no need to add. - return origin; - } - return origin + ":" + G.window.location.port; - } - // Get the user profile for testing purposes public async testGetProfile(): Promise { const resp = await fetchFromHome('/api/profile/user', {credentials: 'include'}); diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 0cf80cd9..ee53d3d5 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -66,7 +66,7 @@ function createMainPage(appModel: AppModel, appObj: App) { } return dom.domComputed(appModel.pageType, (pageType) => { if (pageType === 'home') { - return dom.create(pagePanelsHome, appModel); + return dom.create(pagePanelsHome, appModel, appObj); } else if (pageType === 'billing') { return domAsync(loadBillingPage().then(bp => dom.create(bp.BillingPage, appModel))); } else if (pageType === 'welcome') { @@ -77,8 +77,8 @@ function createMainPage(appModel: AppModel, appObj: App) { }); } -function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel) { - const pageModel = HomeModelImpl.create(owner, appModel); +function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) { + const pageModel = HomeModelImpl.create(owner, appModel, app.clientScope); const leftPanelOpen = Observable.create(owner, true); // Set document title to strings like "Home - Grist" or "Org Name - Grist". diff --git a/app/client/ui/HomeImports.ts b/app/client/ui/HomeImports.ts index 83e2038e..84d23af7 100644 --- a/app/client/ui/HomeImports.ts +++ b/app/client/ui/HomeImports.ts @@ -1,4 +1,6 @@ +import {PluginScreen} from 'app/client/components/PluginScreen'; import {guessTimezone} from 'app/client/lib/guessTimezone'; +import {ImportSourceElement} from 'app/client/lib/ImportSourceElement'; import {IMPORTABLE_EXTENSIONS, uploadFiles} from 'app/client/lib/uploads'; import {AppModel, reportError} from 'app/client/models/AppModel'; import {IProgress} from 'app/client/models/NotifyModel'; @@ -22,6 +24,14 @@ export async function docImport(app: AppModel, workspaceId: number|"unsaved"): P if (!files.length) { return null; } + return await fileImport(files, app, workspaceId); +} + +/** + * Imports a document from a file and returns its docId. + */ +export async function fileImport( + files: File[], app: AppModel, workspaceId: number | "unsaved"): Promise { // There is just one file (thanks to {multiple: false} above). const progressUI = app.notifier.createProgressIndicator(files[0].name, byteString(files[0].size)); const progress = ImportProgress.create(progressUI, progressUI, files[0]); @@ -110,3 +120,44 @@ export class ImportProgress extends Disposable { this._progressUI.setProgress(100 * progress); } } + +/** + * Imports document through a plugin from a home/welcome screen. + */ +export async function importFromPlugin( + app: AppModel, + workspaceId: number | "unsaved", + importSourceElem: ImportSourceElement +) { + const screen = PluginScreen.create(null, importSourceElem.importSource.label); + try { + + const plugin = importSourceElem.plugin; + const handle = screen.renderPlugin(plugin); + const importSource = await importSourceElem.importSourceStub.getImportSource(handle); + plugin.removeRenderTarget(handle); + + if (importSource) { + // If data has been picked, upload it. + const item = importSource.item; + if (item.kind === "fileList") { + const files = item.files.map(({ content, name }) => new File([content], name)); + const docId = await fileImport(files, app, workspaceId); + screen.close(); + return docId; + } else if (item.kind === "url") { + //TODO: importing from url is not yet implemented. + //uploadResult = await fetchURL(this._docComm, item.url); + throw new Error("Url is not supported yet"); + } else { + throw new Error(`Import source of kind ${(item as any).kind} are not yet supported!`); + } + } else { + screen.close(); + return null; + } + } catch (err) { + screen.renderError(err.message); + return null; + } +} diff --git a/app/client/ui/HomeLeftPane.ts b/app/client/ui/HomeLeftPane.ts index 7991e53d..89087741 100644 --- a/app/client/ui/HomeLeftPane.ts +++ b/app/client/ui/HomeLeftPane.ts @@ -1,11 +1,11 @@ import {loadUserManager} from 'app/client/lib/imports'; +import {ImportSourceElement} from 'app/client/lib/ImportSourceElement'; import {reportError} from 'app/client/models/AppModel'; import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {HomeModel} from 'app/client/models/HomeModel'; import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo'; import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton'; -import {docImport} from 'app/client/ui/HomeImports'; -import {createHelpTools, cssLeftPanel, cssScrollPane, cssSectionHeader, cssTools} from 'app/client/ui/LeftPanelCommon'; +import {docImport, importFromPlugin} from 'app/client/ui/HomeImports'; import {cssLinkText, cssPageEntry, cssPageIcon, cssPageLink} from 'app/client/ui/LeftPanelCommon'; import {transientInput} from 'app/client/ui/transientInput'; import {colors, testId} from 'app/client/ui2018/cssVars'; @@ -14,7 +14,9 @@ import {menu, menuIcon, menuItem, upgradableMenuItem, upgradeText} from 'app/cli import {confirmModal} from 'app/client/ui2018/modals'; import * as roles from 'app/common/roles'; import {Workspace} from 'app/common/UserAPI'; -import {computed, dom, DomElementArg, Observable, observable, styled} from 'grainjs'; +import {computed, dom, domComputed, DomElementArg, observable, Observable, styled} from 'grainjs'; +import {createHelpTools, cssLeftPanel, cssScrollPane, + cssSectionHeader, cssTools} from 'app/client/ui/LeftPanelCommon'; export function createHomeLeftPane(leftPanelOpen: Observable, home: HomeModel) { const creating = observable(false); @@ -138,6 +140,23 @@ export async function importDocAndOpen(home: HomeModel) { } } +export async function importFromPluginAndOpen(home: HomeModel, source: ImportSourceElement) { + try { + const destWS = home.newDocWorkspace.get(); + if (!destWS) { return; } + const docId = await importFromPlugin( + home.app, + destWS === "unsaved" ? "unsaved" : destWS.id, + source); + if (docId) { + const doc = await home.app.api.getDoc(docId); + await urlState().pushUrl(docUrl(doc)); + } + } catch (err) { + reportError(err); + } +} + function addMenu(home: HomeModel, creating: Observable): DomElementArg[] { const org = home.app.currentOrg; const orgAccess: roles.Role|null = org ? org.access : null; @@ -152,6 +171,14 @@ function addMenu(home: HomeModel, creating: Observable): DomElementArg[ dom.cls('disabled', !home.newDocWorkspace.get()), testId("dm-import") ), + domComputed(home.importSources, importSources => ([ + ...importSources.map((source, i) => + menuItem(() => importFromPluginAndOpen(home, source), + menuIcon('Import'), + source.importSource.label, + testId(`dm-import-plugin`) + )) + ])), // For workspaces: if ACL says we can create them, but product says we can't, // then offer an upgrade link. upgradableMenuItem(needUpgrade, () => creating.set(true), menuIcon('Folder'), "Create Workspace", diff --git a/app/common/DocListAPI.ts b/app/common/DocListAPI.ts index 883ecc8b..c78ae3e1 100644 --- a/app/common/DocListAPI.ts +++ b/app/common/DocListAPI.ts @@ -1,6 +1,5 @@ import {ActionGroup} from 'app/common/ActionGroup'; import {TableDataAction} from 'app/common/DocActions'; -import {LocalPlugin} from 'app/common/plugin'; import {Role} from 'app/common/roles'; import {StringUnion} from 'app/common/StringUnion'; import {FullUser} from 'app/common/UserAPI'; @@ -43,7 +42,6 @@ export interface OpenLocalDocResult { clientId: string; // the docFD is meaningful only in the context of this session doc: {[tableId: string]: TableDataAction}; log: ActionGroup[]; - plugins: LocalPlugin[]; recoveryMode?: boolean; userOverride?: UserOverride; } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 0bc98671..f0b5f90b 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -2,6 +2,7 @@ import {BillingPage, BillingSubPage, BillingTask} from 'app/common/BillingAPI'; import {OpenDocMode} from 'app/common/DocListAPI'; import {encodeQueryParams, isAffirmative} from 'app/common/gutil'; import {localhostRegex} from 'app/common/LoginState'; +import {LocalPlugin} from 'app/common/plugin'; import {StringUnion} from 'app/common/StringUnion'; import {Document} from 'app/common/UserAPI'; import clone = require('lodash/clone'); @@ -442,6 +443,9 @@ export interface GristLoadConfig { // Google Client Id, used in Google integration (ex: Google Drive Plugin) googleClientId?: string; + + // List of registered plugins (used by HomePluginManager and DocPluginManager) + plugins?: LocalPlugin[]; } // Acceptable org subdomains are alphanumeric (hyphen also allowed) and of diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 4e874c33..b871cd0d 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -98,7 +98,7 @@ export class ApiServer { */ constructor( private _app: express.Application, - private _dbManager: HomeDBManager, + private _dbManager: HomeDBManager ) { this._addEndpoints(); } diff --git a/app/plugin/PluginManifest-ti.ts b/app/plugin/PluginManifest-ti.ts index 231f5600..04ae64f8 100644 --- a/app/plugin/PluginManifest-ti.ts +++ b/app/plugin/PluginManifest-ti.ts @@ -28,6 +28,7 @@ export const BarePlugin = t.iface([], { export const ImportSource = t.iface([], { "label": "string", + "safeHome": t.opt("boolean"), "importSource": "Implementation", "importProcessor": t.opt("Implementation"), }); diff --git a/app/plugin/PluginManifest.ts b/app/plugin/PluginManifest.ts index 6bffef88..c4d60aba 100644 --- a/app/plugin/PluginManifest.ts +++ b/app/plugin/PluginManifest.ts @@ -125,6 +125,13 @@ export interface ImportSource { */ label: string; + /** + * Whether this import source can be exposed on a home screen for all users. Home imports + * support only a safeBrowser component and have no access to current document. Primarily used as + * an external/cloud storage providers. + */ + safeHome?: boolean; + /** * Implementation of ImportSourceAPI. Supports safeBrowser component, which allows you to create * custom UI to show to the user. Or describe UI using a .json or .yml config file and use diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index b3c92d65..3998c2a8 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -9,6 +9,7 @@ import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch'; import {ApiError} from 'app/common/ApiError'; import {getSlugIfNeeded, parseSubdomainStrictly} from 'app/common/gristUrls'; import {removeTrailingSlash} from 'app/common/gutil'; +import {LocalPlugin} from "app/common/plugin"; import {Document as APIDocument} from 'app/common/UserAPI'; import {Document} from "app/gen-server/entity/Document"; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; @@ -29,6 +30,7 @@ export interface AttachOptions { docWorkerMap: IDocWorkerMap|null; sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise; dbManager: HomeDBManager; + plugins: LocalPlugin[]; } /** @@ -149,11 +151,11 @@ async function getWorker(docWorkerMap: IDocWorkerMap, assignmentId: string, } export function attachAppEndpoint(options: AttachOptions): void { - const {app, middleware, docMiddleware, docWorkerMap, forceLogin, sendAppPage, dbManager} = options; + const {app, middleware, docMiddleware, docWorkerMap, forceLogin, sendAppPage, dbManager, plugins} = options; // Per-workspace URLs open the same old Home page, and it's up to the client to notice and // render the right workspace. app.get(['/', '/ws/:wsId', '/p/:page'], ...middleware, expressWrap(async (req, res) => - sendAppPage(req, res, {path: 'app.html', status: 200, config: {}, googleTagManager: 'anon'}))); + sendAppPage(req, res, {path: 'app.html', status: 200, config: {plugins}, googleTagManager: 'anon'}))); app.get('/api/worker/:assignmentId([^/]+)/?*', expressWrap(async (req, res) => { if (!trustOrigin(req, res)) { throw new Error('Unrecognized origin'); } @@ -180,7 +182,7 @@ export function attachAppEndpoint(options: AttachOptions): void { return next(); } if (!docWorkerMap) { - return await sendAppPage(req, res, {path: 'app.html', status: 200, config: {}, + return await sendAppPage(req, res, {path: 'app.html', status: 200, config: {plugins}, googleTagManager: 'anon'}); } const mreq = req as RequestWithLogin; @@ -255,6 +257,7 @@ export function attachAppEndpoint(options: AttachOptions): void { assignmentId: docId, getWorker: {[docId]: customizeDocWorkerUrl(docStatus.docWorker.publicUrl, req)}, getDoc: {[docId]: pruneAPIResult(doc as unknown as APIDocument)}, + plugins }}); }); // The * is a wildcard in express 4, rather than a regex symbol. diff --git a/app/server/lib/DocManager.ts b/app/server/lib/DocManager.ts index 23afe423..008c80f5 100644 --- a/app/server/lib/DocManager.ts +++ b/app/server/lib/DocManager.ts @@ -327,7 +327,6 @@ export class DocManager extends EventEmitter { clientId: docSession.client.clientId, doc: metaTables, log: recentActions, - plugins: activeDoc.docPluginManager.getPlugins(), recoveryMode: activeDoc.recoveryMode, userOverride: await activeDoc.getUserOverride(docSession), }; diff --git a/app/server/lib/DocPluginManager.ts b/app/server/lib/DocPluginManager.ts index 6a7ac64b..2102656e 100644 --- a/app/server/lib/DocPluginManager.ts +++ b/app/server/lib/DocPluginManager.ts @@ -147,7 +147,7 @@ export class DocPluginManager { } /** - * Returns a promise which resolves with the list of plugins definitions. + * Returns a list of plugins definitions. */ public getPlugins(): LocalPlugin[] { return this._localPlugins; diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index f6fdf028..e2c69e59 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -645,7 +645,7 @@ export class FlexServer implements GristServer { } } - public addLandingPages() { + public async addLandingPages() { // TODO: check if isSingleUserMode() path can be removed from this method if (this._check('landing', 'map', isSingleUserMode() ? null : 'homedb')) { return; } this.addSessions(); @@ -710,6 +710,7 @@ export class FlexServer implements GristServer { docWorkerMap: isSingleUserMode() ? null : this._docWorkerMap, sendAppPage: this._sendAppPage, dbManager: this.dbManager, + plugins : (await this._addPluginManager()).getPlugins() }); } diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts index c1f02563..a56a4a2d 100644 --- a/app/server/mergedServerMain.ts +++ b/app/server/mergedServerMain.ts @@ -109,7 +109,7 @@ export async function main(port: number, serverTypes: ServerType[], server.addDocApiForwarder(); } server.addJsonSupport(); - server.addLandingPages(); + await server.addLandingPages(); // todo: add support for home api to standalone app if (!includeApp) { server.addHomeApi();