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 {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;
|
||||
|
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 {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…
Reference in New Issue
Block a user