(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:
Jarosław Sadziński
2021-08-05 17:12:46 +02:00
parent bb55422d9c
commit 4ca47878ca
21 changed files with 348 additions and 142 deletions

View File

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

View File

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

View 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;
`);