diff --git a/app/client/components/CursorMonitor.ts b/app/client/components/CursorMonitor.ts index 7eaa3937..27e91375 100644 --- a/app/client/components/CursorMonitor.ts +++ b/app/client/components/CursorMonitor.ts @@ -52,7 +52,9 @@ export class CursorMonitor extends Disposable { this.autoDispose(doc.cursorPosition.addListener(pos => { // if current position is not restored yet, don't change it if (!this._restored) { return; } - if (pos) { this._storePosition(pos); } + // store position only when we have valid rowId + // for some views (like CustomView) cursor position might not reflect actual row + if (pos && pos.rowId !== undefined) { this._storePosition(pos); } })); } diff --git a/app/client/components/ViewConfigTab.js b/app/client/components/ViewConfigTab.js index 5b51fc7d..0e0f97df 100644 --- a/app/client/components/ViewConfigTab.js +++ b/app/client/components/ViewConfigTab.js @@ -771,20 +771,7 @@ ViewConfigTab.prototype._buildCustomTypeItems = function() { }, { // 2) - showObs: () => activeSection().customDef.mode() === "url", - buildDom: () => kd.scope(activeSection, ({customDef}) => dom('div', - kf.row(18, kf.text(customDef.url, {placeholder: "Full URL of webpage to show"}, dom.testId('ViewConfigTab_url'))), - kf.row(5, "Access", 13, dom(kf.select(customDef.access, ['none', 'read table', 'full']), dom.testId('ViewConfigTab_customView_access'))), - kf.helpRow('none: widget has no access to document.', - kd.style('text-align', 'left'), - kd.style('margin-top', '1.5rem')), - kf.helpRow('read table: widget can read the selected table.', - kd.style('text-align', 'left'), - kd.style('margin-top', '1.5rem')), - kf.helpRow('full: widget can read, modify, and copy the document.', - kd.style('text-align', 'left'), - kd.style('margin-top', '1.5rem')) - )), + // TODO: refactor this part, Custom Widget moved to separate file. }, { // 3) diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index e816017f..702e0f40 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -1,6 +1,7 @@ import * as BaseView from 'app/client/components/BaseView'; import { ColumnRec, FilterRec, TableRec, ViewFieldRec, ViewRec } from 'app/client/models/DocModel'; import * as modelUtil from 'app/client/models/modelUtil'; +import {ICustomWidget} from 'app/common/CustomWidget'; import * as ko from 'knockout'; import { CursorPos, } from 'app/client/components/Cursor'; import { KoArray, } from 'app/client/lib/koArray'; @@ -131,29 +132,36 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> { // Apply `filter` to the field or column identified by `colRef`. setFilter(colRef: number, filter: string): void; + + // Saves custom definition (bundles change) + saveCustomDef(): Promise; } export interface CustomViewSectionDef { /** * The mode. */ - mode: ko.Observable<"url"|"plugin">; + mode: modelUtil.KoSaveableObservable<"url"|"plugin">; /** * The url. */ - url: ko.Observable; + url: modelUtil.KoSaveableObservable; + /** + * Custom widget information. + */ + widgetDef: modelUtil.KoSaveableObservable; /** * Access granted to url. */ - access: ko.Observable; + access: modelUtil.KoSaveableObservable; /** * The plugin id. */ - pluginId: ko.Observable; + pluginId: modelUtil.KoSaveableObservable; /** * The section id. */ - sectionId: ko.Observable; + sectionId: modelUtil.KoSaveableObservable; } // Information about filters for a field or hidden column. @@ -185,7 +193,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): const customViewDefaults = { mode: 'url', - url: '', + url: null, + widgetDef: null, access: '', pluginId: '', sectionId: '' @@ -196,11 +205,16 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): this.customDef = { mode: customDefObj.prop('mode'), url: customDefObj.prop('url'), + widgetDef: customDefObj.prop('widgetDef'), access: customDefObj.prop('access'), pluginId: customDefObj.prop('pluginId'), sectionId: customDefObj.prop('sectionId') }; + this.saveCustomDef = () => { + return customDefObj.save(); + }; + this.themeDef = modelUtil.fieldWithDefault(this.theme, 'form'); this.chartTypeDef = modelUtil.fieldWithDefault(this.chartType, 'bar'); this.view = refRecord(docModel.views, this.parentId); diff --git a/app/client/models/errors.ts b/app/client/models/errors.ts index a322e029..4b6b4861 100644 --- a/app/client/models/errors.ts +++ b/app/client/models/errors.ts @@ -123,7 +123,12 @@ export function reportError(err: Error|string): void { } else { // If we don't recognize it, consider it an application error (bug) that the user should be // able to report. - _notifier.createAppError(err); + if (details?.userError) { + // If we have user friendly error, show it instead. + _notifier.createAppError(Error(details.userError)); + } else { + _notifier.createAppError(err); + } } } } diff --git a/app/client/ui/CustomSectionConfig.ts b/app/client/ui/CustomSectionConfig.ts new file mode 100644 index 00000000..69a156b8 --- /dev/null +++ b/app/client/ui/CustomSectionConfig.ts @@ -0,0 +1,280 @@ +import * as kf from 'app/client/lib/koForm'; +import {ViewSectionRec} from 'app/client/models/DocModel'; +import {reportError} from 'app/client/models/errors'; +import {cssLabel, cssRow, cssTextInput} from 'app/client/ui/RightPanel'; +import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; +import {colors} from 'app/client/ui2018/cssVars'; +import {cssLink} from 'app/client/ui2018/links'; +import {IOptionFull, select} from 'app/client/ui2018/menus'; +import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget'; +import {GristLoadConfig} from 'app/common/gristUrls'; +import {nativeCompare} from 'app/common/gutil'; +import {UserAPI} from 'app/common/UserAPI'; +import {bundleChanges, Computed, Disposable, dom, + makeTestId, MultiHolder, Observable, styled} from 'grainjs'; +import {icon} from 'app/client/ui2018/icons'; + +// Custom URL widget id - used as mock id for selectbox. +const CUSTOM_ID = "custom"; +const testId = makeTestId('test-config-widget-'); + + +/** + * Custom Widget section. + * Allows to select custom widget from the list of available widgets + * (taken from /widgets endpoint), or enter a Custom URL. + * When Custom Widget has a desired access level (in accessLevel field), + * will prompt user to approve it. "None" access level is auto approved, + * so prompt won't be shown. + * + * When gristConfig.enableWidgetRepository is set to false, it will only + * allow to specify Custom URL. + */ + +export class CustomSectionConfig extends Disposable { + // Holds all available widget definitions. + private _widgets: Observable; + // Holds selected option (either custom or a widgetId). + private _selected: Computed; + // Holds custom widget URL. + private _url: Computed; + // Enable or disable widget repository. + private _canSelect = true; + // Selected access level. + private _selectedAccess: Computed; + // When widget is changed, it sets its desired access level. We will prompt + // user to approve or reject it. + private _desiredAccess: Observable; + // Current access level (stored inside a section). + private _currentAccess: Computed; + + constructor(section: ViewSectionRec, api: UserAPI) { + super(); + + // Test if we can offer widget list. + const gristConfig: GristLoadConfig = (window as any).gristConfig || {}; + this._canSelect = gristConfig.enableWidgetRepository ?? true; + + // Array of available widgets - will be updated asynchronously. + this._widgets = Observable.create(this, []); + + if (this._canSelect) { + // From the start we will provide single widget definition + // that was chosen previously. + if (section.customDef.widgetDef.peek()) { + this._widgets.set([section.customDef.widgetDef.peek()!]); + } + // Request for rest of the widgets. + api.getWidgets().then(widgets => { + if (this.isDisposed()) { + return; + } + const existing = section.customDef.widgetDef.peek(); + // Make sure we have current widget in place. + if (existing && !widgets.some(w => w.widgetId === existing.widgetId)) { + widgets.push(existing); + } + this._widgets.set(widgets.sort((a, b) => nativeCompare(a.name.toLowerCase(), b.name.toLowerCase()))); + }).catch(err => { + reportError(err); + }); + } + + // Create temporary variable that will hold blank Custom Url state. When url is blank and widgetDef is not stored + // we can either show "Select Custom Widget" or a Custom Url with a blank url. + // To distinguish those states, we will mark Custom Url state at start (by checking that url is not blank and + // widgetDef is not set). And then switch it during selectbox manipulation. + const wantsToBeCustom = Observable.create(this, + Boolean(section.customDef.url.peek() && !section.customDef.widgetDef.peek()) + ); + + // Selected value from the dropdown (contains widgetId or "custom" string for Custom URL) + this._selected = Computed.create(this, use => { + if (use(section.customDef.widgetDef)) { + return section.customDef.widgetDef.peek()!.widgetId; + } + if (use(section.customDef.url) || use(wantsToBeCustom)) { + return CUSTOM_ID; + } + return null; + }); + this._selected.onWrite(async (value) => { + if (value === CUSTOM_ID) { + // Select Custom URL + bundleChanges(() => { + // Clear url. + section.customDef.url(null); + // Clear widget definition. + section.customDef.widgetDef(null); + // Set intermediate state + wantsToBeCustom.set(true); + // Reset access level to none. + section.customDef.access(AccessLevel.none); + this._desiredAccess.set(AccessLevel.none); + }); + await section.saveCustomDef(); + } else { + // Select Widget + const selectedWidget = this._widgets.get().find(w => w.widgetId === value); + if (!selectedWidget) { + // should not happen + throw new Error("Error accessing widget from the list"); + } + // If user selected the same one, do nothing. + if (section.customDef.widgetDef.peek()?.widgetId === value) { + return; + } + bundleChanges(() => { + // Clear access level + section.customDef.access(AccessLevel.none); + // When widget wants some access, set desired access level. + this._desiredAccess.set(selectedWidget.accessLevel || AccessLevel.none); + // Update widget definition. + section.customDef.widgetDef(selectedWidget); + // Update widget URL. + section.customDef.url(selectedWidget.url); + // Clear intermediate state. + wantsToBeCustom.set(false); + }); + await section.saveCustomDef(); + } + }); + + // Url for the widget, taken either from widget definition, or provided by hand for Custom URL. + // For custom widget, we will store url also in section definition. + this._url = Computed.create(this, use => use(section.customDef.url) || ""); + this._url.onWrite((newUrl) => section.customDef.url.setAndSave(newUrl)); + + // Compute current access level. + this._currentAccess = Computed.create(this, + use => use(section.customDef.access) as AccessLevel || AccessLevel.none); + + // From the start desired access level is the same as current one. + this._desiredAccess = Observable.create(this, this._currentAccess.get()); + + // Selected access level will show desired one, but will updated both (desired and current). + this._selectedAccess = Computed.create(this, use => use(this._desiredAccess)); + this._selectedAccess.onWrite(async newAccess => { + this._desiredAccess.set(newAccess); + await section.customDef.access.setAndSave(newAccess); + }); + + // Clear intermediate state when section changes. + this.autoDispose(section.id.subscribe(() => wantsToBeCustom.set(false))); + this.autoDispose(section.id.subscribe(() => this._reject())); + } + + public buildDom() { + // UI observables holder. + const holder = new MultiHolder(); + + // Show prompt, when desired access level is different from actual one. + const prompt = Computed.create(holder, use => use(this._currentAccess) !== use(this._desiredAccess)); + // If this is empty section or not. + const isSelected = Computed.create(holder, use => Boolean(use(this._selected))); + // If user is using custom url. + const isCustom = Computed.create(holder, use => use(this._selected) === CUSTOM_ID || !this._canSelect); + // Options for the selectbox (all widgets definitions and Custom URL) + const options = Computed.create(holder, use => [ + {label: 'Custom URL', value: 'custom'}, + ...use(this._widgets).map(w => ({label: w.name, value: w.widgetId})), + ]); + // Options for access level. + const levels: IOptionFull[] = [ + {label: 'No document access', value: AccessLevel.none}, + {label: 'Read selected table', value: AccessLevel.read_table}, + {label: 'Full document access', value: AccessLevel.full}, + ]; + return dom( + 'div', + dom.autoDispose(holder), + this._canSelect ? + cssRow( + select(this._selected, options, { + defaultLabel: 'Select Custom Widget', + menuCssClass: cssMenu.className + }), + testId('select') + ) : null, + dom.maybe(isCustom, () => [ + cssRow( + cssTextInput( + this._url, + async value => this._url.set(value), + dom.attr('placeholder', 'Enter Custom URL'), + testId('url') + ) + ), + ]), + cssSection( + cssLink( + dom.attr('href', 'https://support.getgrist.com/widget-custom'), + dom.attr('target', '_blank'), + 'Learn more about custom widgets' + ) + ), + dom.maybe((use) => use(isSelected) || !this._canSelect, () => [ + cssLabel('ACCESS LEVEL'), + cssRow(select(this._selectedAccess, levels), testId('access')), + dom.maybe(prompt, () => + kf.prompt( + {tabindex: '-1'}, + cssColumns( + cssWarningWrapper( + icon('Lock'), + ), + dom('div', + cssConfirmRow( + "Approve requested access level?" + ), + cssConfirmRow( + primaryButton("Accept", + testId('access-accept'), + dom.on('click', () => this._accept())), + basicButton("Reject", + testId('access-reject'), + dom.on('click', () => this._reject())) + ) + ) + ) + ) + ) + ]) + ); + } + + private _accept() { + this._selectedAccess.set(this._desiredAccess.get()); + this._reject(); + } + + private _reject() { + this._desiredAccess.set(this._currentAccess.get()); + } +} + +const cssWarningWrapper = styled('div', ` + padding-left: 8px; + padding-top: 6px; + --icon-color: ${colors.lightGreen} +`); + +const cssColumns = styled('div', ` + display: flex; +`); + +const cssConfirmRow = styled('div', ` + display: flex; + padding: 8px; + gap: 8px; +`); + +const cssSection = styled('div', ` + margin: 16px 16px 12px 16px; +`); + +const cssMenu = styled('div', ` + & > li:first-child { + border-bottom: 1px solid ${colors.mediumGrey}; + } +`); diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 015b7a12..4461d1cf 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -40,6 +40,7 @@ import {bundleChanges, Computed, Disposable, dom, domComputed, DomContents, DomElementArg, DomElementMethod, IDomComponent} from 'grainjs'; import {MultiHolder, Observable, styled, subscribe} from 'grainjs'; import * as ko from 'knockout'; +import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; // Represents a top tab of the right side-pane. const TopTab = StringUnion("pageWidget", "field"); @@ -337,7 +338,7 @@ export class RightPanel extends Disposable { // In the default url mode, allow picking a url and granting/forbidding // access to data. dom.maybe(use => use(activeSection.customDef.mode) === 'url', - () => dom('div', parts[1].buildDom())), + () => dom.create(CustomSectionConfig, activeSection, this._gristDoc.app.topAppModel.api)), ]; } ]), diff --git a/app/common/CustomWidget.ts b/app/common/CustomWidget.ts new file mode 100644 index 00000000..0c091519 --- /dev/null +++ b/app/common/CustomWidget.ts @@ -0,0 +1,39 @@ +/** + * Custom widget manifest definition. + */ +export interface ICustomWidget { + /** + * Widget friendly name, used on the UI. + */ + name: string; + /** + * Widget unique id, probably in npm package format @gristlabs/custom-widget-name. + */ + widgetId: string; + /** + * Custom widget main page URL. + */ + url: string; + /** + * Optional desired access level. + */ + accessLevel?: AccessLevel; +} + +/** + * Widget access level. + */ +export enum AccessLevel { + /** + * Default, no access to Grist. + */ + none = "none", + /** + * Read only access to table the widget is based on. + */ + read_table = "read table", + /** + * Full access to document on user's behalf. + */ + full = "full", +} diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 2428fe9a..a72be186 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -6,6 +6,7 @@ import {BrowserSettings} from 'app/common/BrowserSettings'; import {BulkColValues, TableColValues, UserAction} from 'app/common/DocActions'; import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI'; import {Features} from 'app/common/Features'; +import {ICustomWidget} from 'app/common/CustomWidget'; import {isClient} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs'; @@ -321,6 +322,7 @@ export interface UserAPI { deleteUser(userId: number, name: string): Promise; getBaseUrl(): string; // Get the prefix for all the endpoints this object wraps. forRemoved(): UserAPI; // Get a version of the API that works on removed resources. + getWidgets(): Promise; } /** @@ -428,6 +430,10 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' }); } + public async getWidgets(): Promise { + return await this.requestJson(`${this._url}/api/widgets`, { method: 'GET' }); + } + public async getDoc(docId: string): Promise { return this.requestJson(`${this._url}/api/docs/${docId}`, { method: 'GET' }); } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index b0883c93..22b13463 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -469,6 +469,9 @@ export interface GristLoadConfig { // List of registered plugins (used by HomePluginManager and DocPluginManager) plugins?: LocalPlugin[]; + + // If custom widget list is available. + enableWidgetRepository?: boolean; } // Acceptable org subdomains are alphanumeric (hyphen also allowed) and of diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 6abbb2fd..4e02f7d4 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -12,6 +12,7 @@ import {RequestWithOrg} from 'app/server/lib/extractOrg'; import * as log from 'app/server/lib/log'; import {addPermit, getDocScope, getScope, integerParam, isParameterOn, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils'; +import {IWidgetRepository} from 'app/server/lib/WidgetRepository'; import {Request} from 'express'; import {User} from './entity/User'; @@ -98,7 +99,8 @@ export class ApiServer { */ constructor( private _app: express.Application, - private _dbManager: HomeDBManager + private _dbManager: HomeDBManager, + private _widgetRepository: IWidgetRepository ) { this._addEndpoints(); } @@ -238,6 +240,13 @@ export class ApiServer { return sendReply(req, res, query); })); + // GET /api/widgets/ + // Get all widget definitions from external source. + this._app.get('/api/widgets/', expressWrap(async (req, res) => { + const widgetList = await this._widgetRepository.getWidgets(); + return sendOkReply(req, res, widgetList); + })); + // PATCH /api/docs/:did // Update the specified doc. this._app.patch('/api/docs/:did', expressWrap(async (req, res) => { diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 2650a3c1..5c59bb92 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -54,6 +54,7 @@ import * as shutdown from 'app/server/lib/shutdown'; import {TagChecker} from 'app/server/lib/TagChecker'; import {startTestingHooks} from 'app/server/lib/TestingHooks'; import {addUploadRoute} from 'app/server/lib/uploads'; +import {buildWidgetRepository, IWidgetRepository} from 'app/server/lib/WidgetRepository'; import axios from 'axios'; import * as bodyParser from 'body-parser'; import * as express from 'express'; @@ -121,6 +122,7 @@ export class FlexServer implements GristServer { private _sessionStore: SessionStore; private _storageManager: IDocStorageManager; private _docWorkerMap: IDocWorkerMap; + private _widgetRepository: IWidgetRepository; private _internalPermitStore: IPermitStore; // store for permits that stay within our servers private _externalPermitStore: IPermitStore; // store for permits that pass through outside servers private _disabled: boolean = false; @@ -271,6 +273,11 @@ export class FlexServer implements GristServer { return this._storageManager; } + public getWidgetRepository(): IWidgetRepository { + if (!this._widgetRepository) { throw new Error('no widget repository available'); } + return this._widgetRepository; + } + public addLogging() { if (this._check('logging')) { return; } if (process.env.GRIST_LOG_SKIP_HTTP) { return; } @@ -524,7 +531,7 @@ export class FlexServer implements GristServer { // ApiServer's constructor adds endpoints to the app. // tslint:disable-next-line:no-unused-expression - new ApiServer(this.app, this._dbManager); + new ApiServer(this.app, this._dbManager, this._widgetRepository = buildWidgetRepository()); } public addBillingApi() { diff --git a/app/server/lib/ITestingHooks-ti.ts b/app/server/lib/ITestingHooks-ti.ts index 13ebfa88..3e1ec0d4 100644 --- a/app/server/lib/ITestingHooks-ti.ts +++ b/app/server/lib/ITestingHooks-ti.ts @@ -19,6 +19,7 @@ export const ITestingHooks = t.iface([], { "getDocClientCounts": t.func(t.array(t.tuple("string", "number"))), "setActiveDocTimeout": t.func("number", t.param("seconds", "number")), "setDiscourseConnectVar": t.func(t.union("string", "null"), t.param("varName", "string"), t.param("value", t.union("string", "null"))), + "setWidgetRepositoryUrl": t.func("void", t.param("url", "string")), }); const exportedTypeSuite: t.ITypeSuite = { diff --git a/app/server/lib/ITestingHooks.ts b/app/server/lib/ITestingHooks.ts index 0f3e205c..05ca8679 100644 --- a/app/server/lib/ITestingHooks.ts +++ b/app/server/lib/ITestingHooks.ts @@ -15,4 +15,5 @@ export interface ITestingHooks { getDocClientCounts(): Promise>; setActiveDocTimeout(seconds: number): Promise; setDiscourseConnectVar(varName: string, value: string|null): Promise; + setWidgetRepositoryUrl(url: string): Promise; } diff --git a/app/server/lib/TestingHooks.ts b/app/server/lib/TestingHooks.ts index 7f70f4af..9738098a 100644 --- a/app/server/lib/TestingHooks.ts +++ b/app/server/lib/TestingHooks.ts @@ -12,6 +12,7 @@ import {FlexServer} from './FlexServer'; import {ITestingHooks} from './ITestingHooks'; import ITestingHooksTI from './ITestingHooks-ti'; import {connect, fromCallback} from './serverUtils'; +import {WidgetRepositoryImpl} from 'app/server/lib/WidgetRepository'; const tiCheckers = t.createCheckers(ITestingHooksTI, {UserProfile: t.name("object")}); @@ -194,4 +195,12 @@ export class TestingHooks implements ITestingHooks { } return prev; } + + public async setWidgetRepositoryUrl(url: string): Promise { + const repo = this._server.getWidgetRepository() as WidgetRepositoryImpl; + if (!(repo instanceof WidgetRepositoryImpl)) { + throw new Error("Unsupported widget repository"); + } + repo.testOverrideUrl(url); + } } diff --git a/app/server/lib/WidgetRepository.ts b/app/server/lib/WidgetRepository.ts new file mode 100644 index 00000000..00c3544e --- /dev/null +++ b/app/server/lib/WidgetRepository.ts @@ -0,0 +1,92 @@ +import {ICustomWidget} from 'app/common/CustomWidget'; +import * as log from 'app/server/lib/log'; +import fetch from 'node-fetch'; +import {ApiError} from 'app/common/ApiError'; +import * as LRUCache from 'lru-cache'; + +/** + * Widget Repository returns list of available Custom Widgets. + */ +export interface IWidgetRepository { + getWidgets(): Promise; +} + +// Static url for StaticWidgetRepository +const STATIC_URL = process.env.GRIST_WIDGET_LIST_URL; + +/** + * Default repository that gets list of available widgets from a static URL. + */ +export class WidgetRepositoryImpl implements IWidgetRepository { + constructor(protected _staticUrl = STATIC_URL) {} + + /** + * Method exposed for testing, overrides widget url. + */ + public testOverrideUrl(url: string) { + this._staticUrl = url; + } + + public async getWidgets(): Promise { + if (!this._staticUrl) { + log.warn( + 'WidgetRepository: Widget repository is not configured.' + !STATIC_URL + ? ' Missing GRIST_WIDGET_LIST_URL environmental variable.' + : '' + ); + return []; + } + try { + const response = await fetch(this._staticUrl); + if (!response.ok) { + if (response.status === 404) { + throw new ApiError('WidgetRepository: Remote widget list not found', 404); + } else { + const body = await response.text().catch(() => ''); + throw new ApiError( + `WidgetRepository: Remote server returned an error: ${body || response.statusText}`, response.status + ); + } + } + const widgets = await response.json().catch(() => null); + if (!widgets || !Array.isArray(widgets)) { + throw new ApiError('WidgetRepository: Error reading widget list', 500); + } + return widgets; + } catch (err) { + if (!(err instanceof ApiError)) { + throw new ApiError(String(err), 500); + } + throw err; + } + } +} + +/** + * Version of WidgetRepository that caches successful result for 2 minutes. + */ +class CachedWidgetRepository extends WidgetRepositoryImpl { + private _cache = new LRUCache<1, ICustomWidget[]>({maxAge : 1000 * 60 /* minute */ * 2}); + public async getWidgets() { + if (this._cache.has(1)) { + log.debug("WidgetRepository: Widget list taken from the cache."); + return this._cache.get(1)!; + } + const list = await super.getWidgets(); + // Cache only if there are some widgets. + if (list.length) { this._cache.set(1, list); } + return list; + } + + public testOverrideUrl(url: string) { + super.testOverrideUrl(url); + this._cache.reset(); + } +} + +/** + * Returns widget repository implementation. + */ +export function buildWidgetRepository() { + return new CachedWidgetRepository(); +} diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index f6eeb32f..b52005ba 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -45,6 +45,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial