(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
pull/71/head
Jarosław Sadziński 3 years ago
parent bb55422d9c
commit 4ca47878ca

@ -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);

@ -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<DomContents>(this, null);
private _screen: PluginScreen;
private _parseOptions = Observable.create<ParseOptions>(this, {});
private _sourceInfoArray = Observable.create<SourceInfo[]>(this, []);
private _sourceInfoSelected = Observable.create<SourceInfo|null>(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;

@ -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<DomContents>(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;
`);

@ -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");
}
}

@ -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<string|undefined>;
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<AppModel|null>(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 {

@ -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));

@ -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<SortPref>;
currentView: Observable<ViewPref>;
importSources: Observable<ImportSourceElement[]>;
// The workspace for new docs, or "unsaved" to only allow unsaved-doc creation, or null if the
// user isn't allowed to create a doc.
@ -96,6 +100,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
public readonly singleWorkspace = Observable.create(this, true);
public readonly trashWorkspaces = Observable.create<Workspace[]>(this, []);
public readonly templateWorkspaces = Observable.create<Workspace[]>(this, []);
public readonly importSources = Observable.create<ImportSourceElement[]>(this, []);
// Get the workspace details for the workspace with id of currentWSId.
public readonly currentWS = Computed.create(this, (use) =>
@ -133,7 +138,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(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.

@ -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<any> {
const resp = await fetchFromHome('/api/profile/user', {credentials: 'include'});

@ -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".

@ -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<string | null> {
// 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;
}
}

@ -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<boolean>, home: HomeModel) {
const creating = observable<boolean>(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<boolean>): 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<boolean>): 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",

@ -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;
}

@ -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

@ -98,7 +98,7 @@ export class ApiServer {
*/
constructor(
private _app: express.Application,
private _dbManager: HomeDBManager,
private _dbManager: HomeDBManager
) {
this._addEndpoints();
}

@ -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"),
});

@ -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

@ -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<void>;
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.

@ -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),
};

@ -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;

@ -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()
});
}

@ -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();

Loading…
Cancel
Save