mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
parent
bb55422d9c
commit
4ca47878ca
@ -47,6 +47,7 @@ import {OpenLocalDocResult} from 'app/common/DocListAPI';
|
|||||||
import {HashLink, IDocPage} from 'app/common/gristUrls';
|
import {HashLink, IDocPage} from 'app/common/gristUrls';
|
||||||
import {RecalcWhen} from 'app/common/gristTypes';
|
import {RecalcWhen} from 'app/common/gristTypes';
|
||||||
import {encodeQueryParams, undef, waitObs} from 'app/common/gutil';
|
import {encodeQueryParams, undef, waitObs} from 'app/common/gutil';
|
||||||
|
import {LocalPlugin} from "app/common/plugin";
|
||||||
import {StringUnion} from 'app/common/StringUnion';
|
import {StringUnion} from 'app/common/StringUnion';
|
||||||
import {TableData} from 'app/common/TableData';
|
import {TableData} from 'app/common/TableData';
|
||||||
import {DocStateComparison} from 'app/common/UserAPI';
|
import {DocStateComparison} from 'app/common/UserAPI';
|
||||||
@ -143,6 +144,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
public readonly docComm: DocComm,
|
public readonly docComm: DocComm,
|
||||||
public readonly docPageModel: DocPageModel,
|
public readonly docPageModel: DocPageModel,
|
||||||
openDocResponse: OpenLocalDocResult,
|
openDocResponse: OpenLocalDocResult,
|
||||||
|
plugins: LocalPlugin[],
|
||||||
options: {
|
options: {
|
||||||
comparison?: DocStateComparison // initial comparison with another document
|
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.docData = new DocData(this.docComm, openDocResponse.doc);
|
||||||
this.docModel = new DocModel(this.docData);
|
this.docModel = new DocModel(this.docData);
|
||||||
this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm);
|
this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm);
|
||||||
this.docPluginManager = new DocPluginManager(openDocResponse.plugins, app.getUntrustedContentOrigin(),
|
this.docPluginManager = new DocPluginManager(plugins,
|
||||||
this.docComm, app.clientScope);
|
app.topAppModel.getUntrustedContentOrigin(), this.docComm, app.clientScope);
|
||||||
|
|
||||||
// Maintain the MetaRowModel for the global document info, including docId and peers.
|
// Maintain the MetaRowModel for the global document info, including docId and peers.
|
||||||
this.docInfo = this.docModel.docInfo.getRowModel(1);
|
this.docInfo = this.docModel.docInfo.getRowModel(1);
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import {GristDoc} from "app/client/components/GristDoc";
|
import {GristDoc} from "app/client/components/GristDoc";
|
||||||
import {buildParseOptionsForm, ParseOptionValues} from 'app/client/components/ParseOptions';
|
import {buildParseOptionsForm, ParseOptionValues} from 'app/client/components/ParseOptions';
|
||||||
|
import {PluginScreen} from "app/client/components/PluginScreen";
|
||||||
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
|
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
|
||||||
import {fetchURL, isDriveUrl, selectFiles, uploadFiles} from 'app/client/lib/uploads';
|
import {fetchURL, isDriveUrl, selectFiles, uploadFiles} from 'app/client/lib/uploads';
|
||||||
import {reportError} from 'app/client/models/AppModel';
|
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 {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
|
||||||
import {IOptionFull, linkSelect} from 'app/client/ui2018/menus';
|
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 {DataSourceTransformed, ImportResult, ImportTableResult} from "app/common/ActiveDocAPI";
|
||||||
import {TransformColumn, TransformRule, TransformRuleMap} from "app/common/ActiveDocAPI";
|
import {TransformColumn, TransformRule, TransformRuleMap} from "app/common/ActiveDocAPI";
|
||||||
import {byteString} from "app/common/gutil";
|
import {byteString} from "app/common/gutil";
|
||||||
import {UploadResult} from 'app/common/uploads';
|
import {UploadResult} from 'app/common/uploads';
|
||||||
import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI';
|
import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI';
|
||||||
import {RenderTarget} from 'app/plugin/RenderOptions';
|
|
||||||
import {Computed, Disposable, dom, DomContents, IDisposable, Observable, styled} from 'grainjs';
|
import {Computed, Disposable, dom, DomContents, IDisposable, Observable, styled} from 'grainjs';
|
||||||
|
|
||||||
// Special values for import destinations; null means "new table".
|
// Special values for import destinations; null means "new table".
|
||||||
@ -118,9 +117,8 @@ export class Importer extends Disposable {
|
|||||||
|
|
||||||
private _docComm = this._gristDoc.docComm;
|
private _docComm = this._gristDoc.docComm;
|
||||||
private _uploadResult?: UploadResult;
|
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 _parseOptions = Observable.create<ParseOptions>(this, {});
|
||||||
private _sourceInfoArray = Observable.create<SourceInfo[]>(this, []);
|
private _sourceInfoArray = Observable.create<SourceInfo[]>(this, []);
|
||||||
private _sourceInfoSelected = Observable.create<SourceInfo|null>(this, null);
|
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,
|
constructor(private _gristDoc: GristDoc, private _importSourceElem: ImportSourceElement|null,
|
||||||
private _createPreview: CreatePreviewFunc) {
|
private _createPreview: CreatePreviewFunc) {
|
||||||
super();
|
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'});
|
multiple: true, sizeLimit: 'import'});
|
||||||
} else {
|
} else {
|
||||||
const plugin = this._importSourceElem.plugin;
|
const plugin = this._importSourceElem.plugin;
|
||||||
|
const handle = this._screen.renderPlugin(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 importSource = await this._importSourceElem.importSourceStub.getImportSource(handle);
|
const importSource = await this._importSourceElem.importSourceStub.getImportSource(handle);
|
||||||
plugin.removeRenderTarget(handle);
|
plugin.removeRenderTarget(handle);
|
||||||
|
this._screen.renderSpinner();
|
||||||
if (!this._openModalCtl) {
|
|
||||||
this._showImportDialog();
|
|
||||||
}
|
|
||||||
this._renderSpinner();
|
|
||||||
|
|
||||||
if (importSource) {
|
if (importSource) {
|
||||||
// If data has been picked, upload it.
|
// If data has been picked, upload it.
|
||||||
@ -202,10 +189,7 @@ export class Importer extends Disposable {
|
|||||||
await this._cancelImport();
|
await this._cancelImport();
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (!this._openModalCtl) {
|
this._screen.renderError(err.message);
|
||||||
this._showImportDialog();
|
|
||||||
}
|
|
||||||
this._renderError(err.message);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,8 +255,7 @@ export class Importer extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _reImport(upload: UploadResult) {
|
private async _reImport(upload: UploadResult) {
|
||||||
this._renderSpinner();
|
this._screen.renderSpinner();
|
||||||
this._showImportDialog();
|
|
||||||
try {
|
try {
|
||||||
const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 100};
|
const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 100};
|
||||||
const importResult: ImportResult = await this._docComm.importFiles(
|
const importResult: ImportResult = await this._docComm.importFiles(
|
||||||
@ -300,12 +283,12 @@ export class Importer extends Disposable {
|
|||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Import failed", e);
|
console.warn("Import failed", e);
|
||||||
this._renderError(e.message);
|
this._screen.renderError(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _finishImport(upload: UploadResult) {
|
private async _finishImport(upload: UploadResult) {
|
||||||
this._renderSpinner();
|
this._screen.renderSpinner();
|
||||||
const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 0};
|
const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 0};
|
||||||
const importResult: ImportResult = await this._docComm.finishImportFiles(
|
const importResult: ImportResult = await this._docComm.finishImportFiles(
|
||||||
this._getTransformedDataSource(upload), parseOptions, this._getHiddenTableIds());
|
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;
|
const tableRowModel = this._gristDoc.docModel.dataTables[importResult.tables[0].hiddenTableId].tableMetaRow;
|
||||||
await this._gristDoc.openDocPage(tableRowModel.primaryViewId());
|
await this._gristDoc.openDocPage(tableRowModel.primaryViewId());
|
||||||
}
|
}
|
||||||
this._openModalCtl?.close();
|
this._screen.close();
|
||||||
this.dispose();
|
this.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -323,60 +306,19 @@ export class Importer extends Disposable {
|
|||||||
await this._docComm.cancelImportFiles(
|
await this._docComm.cancelImportFiles(
|
||||||
this._getTransformedDataSource(this._uploadResult), this._getHiddenTableIds());
|
this._getTransformedDataSource(this._uploadResult), this._getHiddenTableIds());
|
||||||
}
|
}
|
||||||
this._openModalCtl?.close();
|
this._screen.close();
|
||||||
this.dispose();
|
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) {
|
private _buildModalTitle(rightElement?: DomContents) {
|
||||||
const title = this._importSourceElem ? this._importSourceElem.importSource.label : 'Import from file';
|
const title = this._importSourceElem ? this._importSourceElem.importSource.label : 'Import from file';
|
||||||
return cssModalHeader(cssModalTitle(title), rightElement);
|
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.
|
// The importer state showing import in progress, with a list of tables, and a preview.
|
||||||
private _renderMain(upload: UploadResult) {
|
private _renderMain(upload: UploadResult) {
|
||||||
const schema = this._parseOptions.get().SCHEMA;
|
const schema = this._parseOptions.get().SCHEMA;
|
||||||
this._importerContent.set([
|
this._screen.render([
|
||||||
this._buildModalTitle(
|
this._buildModalTitle(
|
||||||
schema ? cssActionLink(cssLinkIcon('Settings'), 'Import options',
|
schema ? cssActionLink(cssLinkIcon('Settings'), 'Import options',
|
||||||
testId('importer-options-link'),
|
testId('importer-options-link'),
|
||||||
@ -424,7 +366,7 @@ export class Importer extends Disposable {
|
|||||||
|
|
||||||
// The importer state showing parse options that may be changed.
|
// The importer state showing parse options that may be changed.
|
||||||
private _renderParseOptions(schema: ParseOptionSchema[], upload: UploadResult) {
|
private _renderParseOptions(schema: ParseOptionSchema[], upload: UploadResult) {
|
||||||
this._importerContent.set([
|
this._screen.render([
|
||||||
this._buildModalTitle(),
|
this._buildModalTitle(),
|
||||||
dom.create(buildParseOptionsForm, schema, this._parseOptions.get() as ParseOptionValues,
|
dom.create(buildParseOptionsForm, schema, this._parseOptions.get() as ParseOptionValues,
|
||||||
(p: ParseOptions) => {
|
(p: ParseOptions) => {
|
||||||
@ -466,28 +408,6 @@ const cssLinkIcon = styled(icon, `
|
|||||||
margin-right: 4px;
|
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', `
|
const cssModalHeader = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
115
app/client/components/PluginScreen.ts
Normal file
115
app/client/components/PluginScreen.ts
Normal file
@ -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;
|
||||||
|
`);
|
57
app/client/lib/HomePluginManager.ts
Normal file
57
app/client/lib/HomePluginManager.ts
Normal file
@ -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 {reportError, setErrorNotifier} from 'app/client/models/errors';
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {Notifier} from 'app/client/models/NotifyModel';
|
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 {Features} from 'app/common/Features';
|
||||||
import {GristLoadConfig} from 'app/common/gristUrls';
|
import {GristLoadConfig} from 'app/common/gristUrls';
|
||||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||||
|
import {LocalPlugin} from 'app/common/plugin';
|
||||||
import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
|
import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
|
||||||
import {Computed, Disposable, Observable, subscribe} from 'grainjs';
|
import {Computed, Disposable, Observable, subscribe} from 'grainjs';
|
||||||
|
|
||||||
export {reportError} from 'app/client/models/errors';
|
export {reportError} from 'app/client/models/errors';
|
||||||
|
|
||||||
export type PageType = "doc" | "home" | "billing" | "welcome";
|
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.
|
// TopAppModel is the part of the app model that persists across org and user switches.
|
||||||
export interface TopAppModel {
|
export interface TopAppModel {
|
||||||
@ -20,6 +23,7 @@ export interface TopAppModel {
|
|||||||
currentSubdomain: Observable<string|undefined>;
|
currentSubdomain: Observable<string|undefined>;
|
||||||
|
|
||||||
notifier: Notifier;
|
notifier: Notifier;
|
||||||
|
plugins: LocalPlugin[];
|
||||||
|
|
||||||
// Everything else gets fully rebuilt when the org/user changes. This is to ensure that
|
// 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.
|
// 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.
|
// Rebuilds the AppModel and consequently the AppUI, without changing the user or the org.
|
||||||
reload(): void;
|
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
|
// 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 currentSubdomain = Computed.create(this, urlState().state, (use, s) => s.org);
|
||||||
public readonly notifier = Notifier.create(this);
|
public readonly notifier = Notifier.create(this);
|
||||||
public readonly appObs = Observable.create<AppModel|null>(this, null);
|
public readonly appObs = Observable.create<AppModel|null>(this, null);
|
||||||
|
public readonly plugins: LocalPlugin[] = [];
|
||||||
|
private readonly _gristConfig?: GristLoadConfig;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
window: {gristConfig?: GristLoadConfig},
|
window: {gristConfig?: GristLoadConfig},
|
||||||
@ -68,10 +79,12 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
|||||||
setErrorNotifier(this.notifier);
|
setErrorNotifier(this.notifier);
|
||||||
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
|
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
|
||||||
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
|
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
|
// 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).
|
// and the FullUser to use for it (the user may change when switching orgs).
|
||||||
this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize()));
|
this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize()));
|
||||||
|
this.plugins = this._gristConfig?.plugins || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize(): void {
|
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() {
|
private async _doInitialize() {
|
||||||
this.appObs.set(null);
|
this.appObs.set(null);
|
||||||
try {
|
try {
|
||||||
|
@ -10,8 +10,8 @@ import {cssLeftPanel, cssScrollPane} from 'app/client/ui/LeftPanelCommon';
|
|||||||
import {buildPagesDom} from 'app/client/ui/Pages';
|
import {buildPagesDom} from 'app/client/ui/Pages';
|
||||||
import {openPageWidgetPicker} from 'app/client/ui/PageWidgetPicker';
|
import {openPageWidgetPicker} from 'app/client/ui/PageWidgetPicker';
|
||||||
import {tools} from 'app/client/ui/Tools';
|
import {tools} from 'app/client/ui/Tools';
|
||||||
import {testId} from 'app/client/ui2018/cssVars';
|
|
||||||
import {bigBasicButton} from 'app/client/ui2018/buttons';
|
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 {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus';
|
||||||
import {confirmModal} from 'app/client/ui2018/modals';
|
import {confirmModal} from 'app/client/ui2018/modals';
|
||||||
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
|
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 {getReconnectTimeout} from 'app/common/gutil';
|
||||||
import {canEdit} from 'app/common/roles';
|
import {canEdit} from 'app/common/roles';
|
||||||
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
|
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 {Holder, Observable, subscribe} from 'grainjs';
|
||||||
|
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
|
||||||
|
|
||||||
// tslint:disable:no-console
|
// 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;
|
await this._api.getDocAPI(urlId).compareDoc(comparisonUrlId, { detail: true }) : undefined;
|
||||||
|
|
||||||
const gristDoc = gdModule.GristDoc.create(flow, this._appObj, docComm, this, openDocResponse,
|
const gristDoc = gdModule.GristDoc.create(flow, this._appObj, docComm, this, openDocResponse,
|
||||||
{comparison});
|
this.appModel.topAppModel.plugins, {comparison});
|
||||||
|
|
||||||
// Move ownership of docComm to GristDoc.
|
// Move ownership of docComm to GristDoc.
|
||||||
gristDoc.autoDispose(flow.release(docComm));
|
gristDoc.autoDispose(flow.release(docComm));
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
|
import {ClientScope} from 'app/client/components/ClientScope';
|
||||||
import {guessTimezone} from 'app/client/lib/guessTimezone';
|
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 {localStorageObs} from 'app/client/lib/localStorageObs';
|
||||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||||
import {UserError} from 'app/client/models/errors';
|
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 * as roles from 'app/common/roles';
|
||||||
import {Document, Workspace} from 'app/common/UserAPI';
|
import {Document, Workspace} from 'app/common/UserAPI';
|
||||||
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
|
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
|
||||||
|
import * as moment from 'moment';
|
||||||
import flatten = require('lodash/flatten');
|
import flatten = require('lodash/flatten');
|
||||||
import sortBy = require('lodash/sortBy');
|
import sortBy = require('lodash/sortBy');
|
||||||
import * as moment from 'moment';
|
|
||||||
|
|
||||||
const DELAY_BEFORE_SPINNER_MS = 500;
|
const DELAY_BEFORE_SPINNER_MS = 500;
|
||||||
|
|
||||||
@ -62,6 +65,7 @@ export interface HomeModel {
|
|||||||
|
|
||||||
currentSort: Observable<SortPref>;
|
currentSort: Observable<SortPref>;
|
||||||
currentView: Observable<ViewPref>;
|
currentView: Observable<ViewPref>;
|
||||||
|
importSources: Observable<ImportSourceElement[]>;
|
||||||
|
|
||||||
// The workspace for new docs, or "unsaved" to only allow unsaved-doc creation, or null if the
|
// 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.
|
// 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 singleWorkspace = Observable.create(this, true);
|
||||||
public readonly trashWorkspaces = Observable.create<Workspace[]>(this, []);
|
public readonly trashWorkspaces = Observable.create<Workspace[]>(this, []);
|
||||||
public readonly templateWorkspaces = 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.
|
// Get the workspace details for the workspace with id of currentWSId.
|
||||||
public readonly currentWS = Computed.create(this, (use) =>
|
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);
|
private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);
|
||||||
|
|
||||||
constructor(private _app: AppModel) {
|
constructor(private _app: AppModel, clientScope: ClientScope) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
if (!this.app.currentValidUser) {
|
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.autoDispose(subscribe(this.currentPage, this.currentWSId, (use) =>
|
||||||
this._updateWorkspaces().catch(reportError)));
|
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.
|
// Accessor for the AppModel containing this HomeModel.
|
||||||
|
@ -63,7 +63,7 @@ export class App extends DisposableWithEvents {
|
|||||||
this.autoDispose(Clipboard.create(this));
|
this.autoDispose(Clipboard.create(this));
|
||||||
} else {
|
} else {
|
||||||
// On mobile, we do not want to keep focus on a special textarea (which would cause unwanted
|
// 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
|
// '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.
|
// input), by making document.body focusable and using a FocusLayer with it as the default.
|
||||||
document.body.setAttribute('tabindex', '-1');
|
document.body.setAttribute('tabindex', '-1');
|
||||||
@ -172,7 +172,7 @@ export class App extends DisposableWithEvents {
|
|||||||
this.autoDispose(createAppUI(this.topAppModel, this));
|
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
|
// 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.
|
// "same-origin"). So this silly callback is for tests to generate a fake error.
|
||||||
public testTriggerError(msg: string) { throw new Error(msg); }
|
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
|
// 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(), ...)
|
// 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 { ... }
|
// .some-old-class.newui { ... }
|
||||||
public addNewUIClass(): DomElementMethod {
|
public addNewUIClass(): DomElementMethod {
|
||||||
return (elem) => { if (this.useNewUI) { elem.classList.add('newui'); } };
|
return (elem) => { if (this.useNewUI) { elem.classList.add('newui'); } };
|
||||||
@ -206,28 +206,6 @@ export class App extends DisposableWithEvents {
|
|||||||
return true;
|
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
|
// Get the user profile for testing purposes
|
||||||
public async testGetProfile(): Promise<any> {
|
public async testGetProfile(): Promise<any> {
|
||||||
const resp = await fetchFromHome('/api/profile/user', {credentials: 'include'});
|
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) => {
|
return dom.domComputed(appModel.pageType, (pageType) => {
|
||||||
if (pageType === 'home') {
|
if (pageType === 'home') {
|
||||||
return dom.create(pagePanelsHome, appModel);
|
return dom.create(pagePanelsHome, appModel, appObj);
|
||||||
} else if (pageType === 'billing') {
|
} else if (pageType === 'billing') {
|
||||||
return domAsync(loadBillingPage().then(bp => dom.create(bp.BillingPage, appModel)));
|
return domAsync(loadBillingPage().then(bp => dom.create(bp.BillingPage, appModel)));
|
||||||
} else if (pageType === 'welcome') {
|
} else if (pageType === 'welcome') {
|
||||||
@ -77,8 +77,8 @@ function createMainPage(appModel: AppModel, appObj: App) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel) {
|
function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) {
|
||||||
const pageModel = HomeModelImpl.create(owner, appModel);
|
const pageModel = HomeModelImpl.create(owner, appModel, app.clientScope);
|
||||||
const leftPanelOpen = Observable.create(owner, true);
|
const leftPanelOpen = Observable.create(owner, true);
|
||||||
|
|
||||||
// Set document title to strings like "Home - Grist" or "Org Name - Grist".
|
// 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 {guessTimezone} from 'app/client/lib/guessTimezone';
|
||||||
|
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
|
||||||
import {IMPORTABLE_EXTENSIONS, uploadFiles} from 'app/client/lib/uploads';
|
import {IMPORTABLE_EXTENSIONS, uploadFiles} from 'app/client/lib/uploads';
|
||||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||||
import {IProgress} from 'app/client/models/NotifyModel';
|
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; }
|
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).
|
// There is just one file (thanks to {multiple: false} above).
|
||||||
const progressUI = app.notifier.createProgressIndicator(files[0].name, byteString(files[0].size));
|
const progressUI = app.notifier.createProgressIndicator(files[0].name, byteString(files[0].size));
|
||||||
const progress = ImportProgress.create(progressUI, progressUI, files[0]);
|
const progress = ImportProgress.create(progressUI, progressUI, files[0]);
|
||||||
@ -110,3 +120,44 @@ export class ImportProgress extends Disposable {
|
|||||||
this._progressUI.setProgress(100 * progress);
|
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 {loadUserManager} from 'app/client/lib/imports';
|
||||||
|
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
|
||||||
import {reportError} from 'app/client/models/AppModel';
|
import {reportError} from 'app/client/models/AppModel';
|
||||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||||
import {HomeModel} from 'app/client/models/HomeModel';
|
import {HomeModel} from 'app/client/models/HomeModel';
|
||||||
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
|
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
|
||||||
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
|
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
|
||||||
import {docImport} from 'app/client/ui/HomeImports';
|
import {docImport, importFromPlugin} from 'app/client/ui/HomeImports';
|
||||||
import {createHelpTools, cssLeftPanel, cssScrollPane, cssSectionHeader, cssTools} from 'app/client/ui/LeftPanelCommon';
|
|
||||||
import {cssLinkText, cssPageEntry, cssPageIcon, cssPageLink} from 'app/client/ui/LeftPanelCommon';
|
import {cssLinkText, cssPageEntry, cssPageIcon, cssPageLink} from 'app/client/ui/LeftPanelCommon';
|
||||||
import {transientInput} from 'app/client/ui/transientInput';
|
import {transientInput} from 'app/client/ui/transientInput';
|
||||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
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 {confirmModal} from 'app/client/ui2018/modals';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
import {Workspace} from 'app/common/UserAPI';
|
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) {
|
export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: HomeModel) {
|
||||||
const creating = observable<boolean>(false);
|
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[] {
|
function addMenu(home: HomeModel, creating: Observable<boolean>): DomElementArg[] {
|
||||||
const org = home.app.currentOrg;
|
const org = home.app.currentOrg;
|
||||||
const orgAccess: roles.Role|null = org ? org.access : null;
|
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()),
|
dom.cls('disabled', !home.newDocWorkspace.get()),
|
||||||
testId("dm-import")
|
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,
|
// For workspaces: if ACL says we can create them, but product says we can't,
|
||||||
// then offer an upgrade link.
|
// then offer an upgrade link.
|
||||||
upgradableMenuItem(needUpgrade, () => creating.set(true), menuIcon('Folder'), "Create Workspace",
|
upgradableMenuItem(needUpgrade, () => creating.set(true), menuIcon('Folder'), "Create Workspace",
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import {ActionGroup} from 'app/common/ActionGroup';
|
import {ActionGroup} from 'app/common/ActionGroup';
|
||||||
import {TableDataAction} from 'app/common/DocActions';
|
import {TableDataAction} from 'app/common/DocActions';
|
||||||
import {LocalPlugin} from 'app/common/plugin';
|
|
||||||
import {Role} from 'app/common/roles';
|
import {Role} from 'app/common/roles';
|
||||||
import {StringUnion} from 'app/common/StringUnion';
|
import {StringUnion} from 'app/common/StringUnion';
|
||||||
import {FullUser} from 'app/common/UserAPI';
|
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
|
clientId: string; // the docFD is meaningful only in the context of this session
|
||||||
doc: {[tableId: string]: TableDataAction};
|
doc: {[tableId: string]: TableDataAction};
|
||||||
log: ActionGroup[];
|
log: ActionGroup[];
|
||||||
plugins: LocalPlugin[];
|
|
||||||
recoveryMode?: boolean;
|
recoveryMode?: boolean;
|
||||||
userOverride?: UserOverride;
|
userOverride?: UserOverride;
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import {BillingPage, BillingSubPage, BillingTask} from 'app/common/BillingAPI';
|
|||||||
import {OpenDocMode} from 'app/common/DocListAPI';
|
import {OpenDocMode} from 'app/common/DocListAPI';
|
||||||
import {encodeQueryParams, isAffirmative} from 'app/common/gutil';
|
import {encodeQueryParams, isAffirmative} from 'app/common/gutil';
|
||||||
import {localhostRegex} from 'app/common/LoginState';
|
import {localhostRegex} from 'app/common/LoginState';
|
||||||
|
import {LocalPlugin} from 'app/common/plugin';
|
||||||
import {StringUnion} from 'app/common/StringUnion';
|
import {StringUnion} from 'app/common/StringUnion';
|
||||||
import {Document} from 'app/common/UserAPI';
|
import {Document} from 'app/common/UserAPI';
|
||||||
import clone = require('lodash/clone');
|
import clone = require('lodash/clone');
|
||||||
@ -442,6 +443,9 @@ export interface GristLoadConfig {
|
|||||||
|
|
||||||
// Google Client Id, used in Google integration (ex: Google Drive Plugin)
|
// Google Client Id, used in Google integration (ex: Google Drive Plugin)
|
||||||
googleClientId?: string;
|
googleClientId?: string;
|
||||||
|
|
||||||
|
// List of registered plugins (used by HomePluginManager and DocPluginManager)
|
||||||
|
plugins?: LocalPlugin[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Acceptable org subdomains are alphanumeric (hyphen also allowed) and of
|
// Acceptable org subdomains are alphanumeric (hyphen also allowed) and of
|
||||||
|
@ -98,7 +98,7 @@ export class ApiServer {
|
|||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private _app: express.Application,
|
private _app: express.Application,
|
||||||
private _dbManager: HomeDBManager,
|
private _dbManager: HomeDBManager
|
||||||
) {
|
) {
|
||||||
this._addEndpoints();
|
this._addEndpoints();
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,7 @@ export const BarePlugin = t.iface([], {
|
|||||||
|
|
||||||
export const ImportSource = t.iface([], {
|
export const ImportSource = t.iface([], {
|
||||||
"label": "string",
|
"label": "string",
|
||||||
|
"safeHome": t.opt("boolean"),
|
||||||
"importSource": "Implementation",
|
"importSource": "Implementation",
|
||||||
"importProcessor": t.opt("Implementation"),
|
"importProcessor": t.opt("Implementation"),
|
||||||
});
|
});
|
||||||
|
@ -125,6 +125,13 @@ export interface ImportSource {
|
|||||||
*/
|
*/
|
||||||
label: string;
|
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
|
* 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
|
* 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 {ApiError} from 'app/common/ApiError';
|
||||||
import {getSlugIfNeeded, parseSubdomainStrictly} from 'app/common/gristUrls';
|
import {getSlugIfNeeded, parseSubdomainStrictly} from 'app/common/gristUrls';
|
||||||
import {removeTrailingSlash} from 'app/common/gutil';
|
import {removeTrailingSlash} from 'app/common/gutil';
|
||||||
|
import {LocalPlugin} from "app/common/plugin";
|
||||||
import {Document as APIDocument} from 'app/common/UserAPI';
|
import {Document as APIDocument} from 'app/common/UserAPI';
|
||||||
import {Document} from "app/gen-server/entity/Document";
|
import {Document} from "app/gen-server/entity/Document";
|
||||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||||
@ -29,6 +30,7 @@ export interface AttachOptions {
|
|||||||
docWorkerMap: IDocWorkerMap|null;
|
docWorkerMap: IDocWorkerMap|null;
|
||||||
sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>;
|
sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>;
|
||||||
dbManager: HomeDBManager;
|
dbManager: HomeDBManager;
|
||||||
|
plugins: LocalPlugin[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -149,11 +151,11 @@ async function getWorker(docWorkerMap: IDocWorkerMap, assignmentId: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function attachAppEndpoint(options: AttachOptions): void {
|
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
|
// Per-workspace URLs open the same old Home page, and it's up to the client to notice and
|
||||||
// render the right workspace.
|
// render the right workspace.
|
||||||
app.get(['/', '/ws/:wsId', '/p/:page'], ...middleware, expressWrap(async (req, res) =>
|
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) => {
|
app.get('/api/worker/:assignmentId([^/]+)/?*', expressWrap(async (req, res) => {
|
||||||
if (!trustOrigin(req, res)) { throw new Error('Unrecognized origin'); }
|
if (!trustOrigin(req, res)) { throw new Error('Unrecognized origin'); }
|
||||||
@ -180,7 +182,7 @@ export function attachAppEndpoint(options: AttachOptions): void {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
if (!docWorkerMap) {
|
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'});
|
googleTagManager: 'anon'});
|
||||||
}
|
}
|
||||||
const mreq = req as RequestWithLogin;
|
const mreq = req as RequestWithLogin;
|
||||||
@ -255,6 +257,7 @@ export function attachAppEndpoint(options: AttachOptions): void {
|
|||||||
assignmentId: docId,
|
assignmentId: docId,
|
||||||
getWorker: {[docId]: customizeDocWorkerUrl(docStatus.docWorker.publicUrl, req)},
|
getWorker: {[docId]: customizeDocWorkerUrl(docStatus.docWorker.publicUrl, req)},
|
||||||
getDoc: {[docId]: pruneAPIResult(doc as unknown as APIDocument)},
|
getDoc: {[docId]: pruneAPIResult(doc as unknown as APIDocument)},
|
||||||
|
plugins
|
||||||
}});
|
}});
|
||||||
});
|
});
|
||||||
// The * is a wildcard in express 4, rather than a regex symbol.
|
// 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,
|
clientId: docSession.client.clientId,
|
||||||
doc: metaTables,
|
doc: metaTables,
|
||||||
log: recentActions,
|
log: recentActions,
|
||||||
plugins: activeDoc.docPluginManager.getPlugins(),
|
|
||||||
recoveryMode: activeDoc.recoveryMode,
|
recoveryMode: activeDoc.recoveryMode,
|
||||||
userOverride: await activeDoc.getUserOverride(docSession),
|
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[] {
|
public getPlugins(): LocalPlugin[] {
|
||||||
return this._localPlugins;
|
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
|
// TODO: check if isSingleUserMode() path can be removed from this method
|
||||||
if (this._check('landing', 'map', isSingleUserMode() ? null : 'homedb')) { return; }
|
if (this._check('landing', 'map', isSingleUserMode() ? null : 'homedb')) { return; }
|
||||||
this.addSessions();
|
this.addSessions();
|
||||||
@ -710,6 +710,7 @@ export class FlexServer implements GristServer {
|
|||||||
docWorkerMap: isSingleUserMode() ? null : this._docWorkerMap,
|
docWorkerMap: isSingleUserMode() ? null : this._docWorkerMap,
|
||||||
sendAppPage: this._sendAppPage,
|
sendAppPage: this._sendAppPage,
|
||||||
dbManager: this.dbManager,
|
dbManager: this.dbManager,
|
||||||
|
plugins : (await this._addPluginManager()).getPlugins()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,7 +109,7 @@ export async function main(port: number, serverTypes: ServerType[],
|
|||||||
server.addDocApiForwarder();
|
server.addDocApiForwarder();
|
||||||
}
|
}
|
||||||
server.addJsonSupport();
|
server.addJsonSupport();
|
||||||
server.addLandingPages();
|
await server.addLandingPages();
|
||||||
// todo: add support for home api to standalone app
|
// todo: add support for home api to standalone app
|
||||||
if (!includeApp) {
|
if (!includeApp) {
|
||||||
server.addHomeApi();
|
server.addHomeApi();
|
||||||
|
Loading…
Reference in New Issue
Block a user