mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) support for bundling custom widgets with the Grist app
Summary: This adds support for bundling custom widgets with the Grist app, as follows: * Adds a new `widgets` component to plugins mechanism. * When a set of widgets is provided in a plugin, the html/js/css assets for those widgets are served on the existing untrusted user content port. * Any bundled `grist-plugin-api.js` will be served with the Grist app's own version of that file. It is important that bundled widgets not refer to https://docs.getgrist.com for the plugin js, since they must be capable of working offline. * The logic for configuring that port is updated a bit. * I removed the CustomAttachedView class in favor of applying settings of bundled custom widgets more directly, without modification on view. Any Grist installation via docker will need an extra step now, since there is an extra port that needs exposing for full functionality. I did add a `GRIST_TRUST_PLUGINS` option for anyone who really doesn't want to do this, and would prefer to trust the plugins and have them served on the same port. Actually making use of bundling will be another step. It'll be important to mesh it with our SaaS's use of APP_STATIC_URL for serving most static assets. Design sketch: https://grist.quip.com/bJlWACWzr2R9/Bundled-custom-widgets Test Plan: added a test Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4069
This commit is contained in:
@@ -1,46 +1,11 @@
|
||||
import {AccessLevel} from "app/common/CustomWidget";
|
||||
import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec";
|
||||
import {CustomView} from "app/client/components/CustomView";
|
||||
import {GristDoc} from "app/client/components/GristDoc";
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import { CustomView, CustomViewSettings } from "app/client/components/CustomView";
|
||||
import { AccessLevel } from "app/common/CustomWidget";
|
||||
|
||||
//Abstract class for more future inheritances
|
||||
abstract class CustomAttachedView extends CustomView {
|
||||
public override create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
|
||||
super.create(gristDoc, viewSectionModel);
|
||||
if (viewSectionModel.customDef.access.peek() !== AccessLevel.full) {
|
||||
void viewSectionModel.customDef.access.setAndSave(AccessLevel.full).catch((err)=>{
|
||||
if (err?.code === "ACL_DENY") {
|
||||
// do nothing, we might be in a readonly mode.
|
||||
return;
|
||||
}
|
||||
reportError(err);
|
||||
});
|
||||
}
|
||||
|
||||
const widgetsApi = this.gristDoc.app.topAppModel.api;
|
||||
widgetsApi.getWidgets().then(async result=>{
|
||||
const widget = result.find(w=>w.name == this.getWidgetName());
|
||||
if (widget && this.customDef.url.peek() !== widget.url) {
|
||||
await this.customDef.url.setAndSave(widget.url);
|
||||
}
|
||||
}).catch((err)=>{
|
||||
if (err?.code !== "ACL_DENY") {
|
||||
// TODO: revisit it later. getWidgets() is async call, and non of the code
|
||||
// above is checking if we are still alive.
|
||||
console.error(err);
|
||||
} else {
|
||||
// do nothing, we might be in a readonly mode.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract getWidgetName(): string;
|
||||
|
||||
}
|
||||
|
||||
export class CustomCalendarView extends CustomAttachedView {
|
||||
protected getWidgetName(): string {
|
||||
return "Calendar";
|
||||
export class CustomCalendarView extends CustomView {
|
||||
protected getBuiltInSettings(): CustomViewSettings {
|
||||
return {
|
||||
widgetId: '@gristlabs/widget-calendar',
|
||||
accessLevel: AccessLevel.full,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,17 @@ import {dom as grains} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
import defaults = require('lodash/defaults');
|
||||
|
||||
/**
|
||||
*
|
||||
* Built in settings for a custom widget. Used when the custom
|
||||
* widget is the implementation of a native-looking widget,
|
||||
* for example the calendar widget.
|
||||
*
|
||||
*/
|
||||
export interface CustomViewSettings {
|
||||
widgetId?: string;
|
||||
accessLevel?: AccessLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* CustomView components displays arbitrary html. There are two modes available, in the "url" mode
|
||||
@@ -83,11 +94,10 @@ export class CustomView extends Disposable {
|
||||
|
||||
private _frame: WidgetFrame; // plugin frame (holding external page)
|
||||
|
||||
|
||||
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
|
||||
BaseView.call(this as any, gristDoc, viewSectionModel, { 'addNewRow': true });
|
||||
|
||||
this.customDef = this.viewSection.customDef;
|
||||
this.customDef = this.viewSection.customDef;
|
||||
|
||||
this.autoDisposeCallback(() => {
|
||||
if (this._customSection) {
|
||||
@@ -107,16 +117,20 @@ export class CustomView extends Disposable {
|
||||
this._updatePluginInstance();
|
||||
}
|
||||
|
||||
|
||||
public async triggerPrint() {
|
||||
if (!this.isDisposed() && this._frame) {
|
||||
return await this._frame.callRemote('print');
|
||||
}
|
||||
}
|
||||
|
||||
protected getBuiltInSettings(): CustomViewSettings {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected getEmptyWidgetPage(): string {
|
||||
return new URL("custom-widget.html", getGristConfig().homeUrl!).href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a plugin instance that matches the plugin id, update the `found` observables, then tries to
|
||||
* find a matching section.
|
||||
@@ -156,13 +170,13 @@ export class CustomView extends Disposable {
|
||||
}
|
||||
|
||||
private _buildDom() {
|
||||
const {mode, url, access, renderAfterReady} = this.customDef;
|
||||
const {mode, url, access, renderAfterReady, widgetDef, widgetId, pluginId} = this.customDef;
|
||||
const showPlugin = ko.pureComputed(() => this.customDef.mode() === "plugin");
|
||||
const showAfterReady = () => {
|
||||
// The empty widget page calls `grist.ready()`.
|
||||
if (!url()) { return true; }
|
||||
if (!url() && !widgetId()) { return true; }
|
||||
|
||||
return this.customDef.widgetDef()?.renderAfterReady ?? renderAfterReady();
|
||||
return renderAfterReady();
|
||||
};
|
||||
|
||||
// When both plugin and section are not found, let's show only plugin notification.
|
||||
@@ -172,18 +186,27 @@ export class CustomView extends Disposable {
|
||||
// For the view to update when switching from one section to another one, the computed
|
||||
// observable must always notify.
|
||||
.extend({notify: 'always'});
|
||||
// Some widgets have built-in settings that should override anything
|
||||
// that is in the rest of the view options. Ideally, everything would
|
||||
// be consistent. We could fix inconsistencies if we find them, but
|
||||
// we are not guaranteed to have write privileges at this point.
|
||||
const builtInSettings = this.getBuiltInSettings();
|
||||
return dom('div.flexauto.flexvbox.custom_view_container',
|
||||
dom.autoDispose(showPlugin),
|
||||
dom.autoDispose(showPluginNotification),
|
||||
dom.autoDispose(showSectionNotification),
|
||||
dom.autoDispose(showPluginContent),
|
||||
// todo: should display content in webview when running electron
|
||||
kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) =>
|
||||
// prefer widgetId; spelunk in widgetDef for older docs
|
||||
kd.scope(() => [mode(), url(), access(), widgetId() || widgetDef()?.widgetId || '', pluginId()],
|
||||
([_mode, _url, _access, _widgetId, _pluginId]: string[]) =>
|
||||
_mode === "url" ?
|
||||
this._buildIFrame({
|
||||
baseUrl: _url,
|
||||
access: (_access as AccessLevel || AccessLevel.none),
|
||||
access: builtInSettings.accessLevel || (_access as AccessLevel || AccessLevel.none),
|
||||
showAfterReady: showAfterReady(),
|
||||
widgetId: builtInSettings.widgetId || _widgetId,
|
||||
pluginId: _pluginId,
|
||||
})
|
||||
: null
|
||||
),
|
||||
@@ -213,12 +236,16 @@ export class CustomView extends Disposable {
|
||||
baseUrl: string|null,
|
||||
access: AccessLevel,
|
||||
showAfterReady?: boolean,
|
||||
widgetId?: string|null,
|
||||
pluginId?: string
|
||||
}) {
|
||||
const {baseUrl, access, showAfterReady} = options;
|
||||
const {baseUrl, access, showAfterReady, widgetId, pluginId} = options;
|
||||
const documentSettings = this.gristDoc.docData.docSettings();
|
||||
const readonly = this.gristDoc.isReadonly.get();
|
||||
return grains.create(WidgetFrame, {
|
||||
url: baseUrl || this.getEmptyWidgetPage(),
|
||||
widgetId,
|
||||
pluginId,
|
||||
access,
|
||||
preferences:
|
||||
{
|
||||
@@ -273,7 +300,8 @@ export class CustomView extends Disposable {
|
||||
}
|
||||
// allow menus to close if any
|
||||
closeRegisteredMenu();
|
||||
})
|
||||
}),
|
||||
gristDoc: this.gristDoc,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import {hooks} from 'app/client/Hooks';
|
||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||
import {makeTestId} from 'app/client/lib/domUtils';
|
||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {AccessLevel, isSatisfied} from 'app/common/CustomWidget';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
|
||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
|
||||
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
|
||||
@@ -45,6 +46,15 @@ export interface WidgetFrameOptions {
|
||||
* Url of external page. Iframe is rebuild each time the URL changes.
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* ID of widget, if known. When set, the url for the specified widget
|
||||
* in the WidgetRepository, if found, will take precedence.
|
||||
*/
|
||||
widgetId?: string|null;
|
||||
/**
|
||||
* ID of the plugin that provided the widget (if it came from a plugin).
|
||||
*/
|
||||
pluginId?: string;
|
||||
/**
|
||||
* Assigned access level. Iframe is rebuild each time access level is changed.
|
||||
*/
|
||||
@@ -72,6 +82,10 @@ export interface WidgetFrameOptions {
|
||||
* Optional language to use for the widget.
|
||||
*/
|
||||
preferences: {language?: string, timeZone?: any, currency?: string, culture?: string};
|
||||
/**
|
||||
* The containing document.
|
||||
*/
|
||||
gristDoc: GristDoc;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +100,7 @@ export class WidgetFrame extends DisposableWithEvents {
|
||||
private _readyCalled = Observable.create(this, false);
|
||||
// Whether the iframe is visible.
|
||||
private _visible = Observable.create(this, !this._options.showAfterReady);
|
||||
private readonly _widget = Observable.create<ICustomWidget|null>(this, null);
|
||||
|
||||
constructor(private _options: WidgetFrameOptions) {
|
||||
super();
|
||||
@@ -112,7 +127,10 @@ export class WidgetFrame extends DisposableWithEvents {
|
||||
|
||||
// Call custom configuration handler.
|
||||
_options.configure?.(this);
|
||||
|
||||
this._checkWidgetRepository().catch(reportError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach an EventSource with desired access level.
|
||||
*/
|
||||
@@ -166,6 +184,21 @@ export class WidgetFrame extends DisposableWithEvents {
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const onElem = this._options.onElem ?? ((el: HTMLIFrameElement) => el);
|
||||
return onElem(
|
||||
(this._iframe = dom(
|
||||
'iframe',
|
||||
dom.style('visibility', use => use(this._visible) ? 'visible' : 'hidden'),
|
||||
dom.cls('clipboard_focus'),
|
||||
dom.cls('custom_view'),
|
||||
dom.attr('src', use => this._getUrl(use(this._widget))),
|
||||
hooks.iframeAttributes,
|
||||
testId('ready', this._readyCalled),
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
private _getUrl(widget: ICustomWidget|null): string {
|
||||
// Append access level to query string.
|
||||
const urlWithAccess = (url: string) => {
|
||||
if (!url) {
|
||||
@@ -175,23 +208,12 @@ export class WidgetFrame extends DisposableWithEvents {
|
||||
urlObj.searchParams.append('access', this._options.access);
|
||||
urlObj.searchParams.append('readonly', String(this._options.readonly));
|
||||
// Append user and document preferences to query string.
|
||||
const settingsParams = new URLSearchParams(this._options.preferences);
|
||||
settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value));
|
||||
const settingsParams = new URLSearchParams(this._options.preferences);
|
||||
settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value));
|
||||
return urlObj.href;
|
||||
};
|
||||
const fullUrl = urlWithAccess(this._options.url);
|
||||
const onElem = this._options.onElem ?? ((el: HTMLIFrameElement) => el);
|
||||
return onElem(
|
||||
(this._iframe = dom('iframe',
|
||||
dom.style('visibility', use => use(this._visible) ? 'visible' : 'hidden'),
|
||||
dom.cls('clipboard_focus'),
|
||||
dom.cls('custom_view'), {
|
||||
src: fullUrl,
|
||||
...hooks.iframeAttributes,
|
||||
},
|
||||
testId('ready', this._readyCalled),
|
||||
))
|
||||
);
|
||||
const url = widget?.url || this._options.url || 'about:blank';
|
||||
return urlWithAccess(url);
|
||||
}
|
||||
|
||||
private _onMessage(event: MessageEvent) {
|
||||
@@ -217,6 +239,19 @@ export class WidgetFrame extends DisposableWithEvents {
|
||||
this._rpc.receiveMessage(event.data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If we have a widgetId, look it up in the WidgetRepository and
|
||||
* get the best URL we can for it.
|
||||
*/
|
||||
private async _checkWidgetRepository() {
|
||||
const {widgetId, pluginId} = this._options;
|
||||
if (this.isDisposed() || !widgetId) { return; }
|
||||
const widgets = await this._options.gristDoc.app.topAppModel.getWidgets();
|
||||
if (this.isDisposed()) { return; }
|
||||
const widget = matchWidget(widgets, {widgetId, pluginId});
|
||||
this._widget.set(widget || null);
|
||||
}
|
||||
}
|
||||
|
||||
const throwError = (access: AccessLevel) => {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
|
||||
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
|
||||
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
|
||||
import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars';
|
||||
import {AsyncCreate} from 'app/common/AsyncCreate';
|
||||
import {ICustomWidget} from 'app/common/CustomWidget';
|
||||
import {OrgUsageSummary} from 'app/common/DocUsage';
|
||||
import {Features, isLegacyPlan, Product} from 'app/common/Features';
|
||||
import {GristLoadConfig, IGristUrlState} from 'app/common/gristUrls';
|
||||
@@ -61,6 +63,8 @@ export interface TopAppModel {
|
||||
orgs: Observable<Organization[]>;
|
||||
users: Observable<FullUser[]>;
|
||||
|
||||
customWidgets: Observable<ICustomWidget[]|null>;
|
||||
|
||||
// Reinitialize the app. This is called when org or user changes.
|
||||
initialize(): void;
|
||||
|
||||
@@ -75,6 +79,17 @@ export interface TopAppModel {
|
||||
* Reloads orgs and accounts for current user.
|
||||
*/
|
||||
fetchUsersAndOrgs(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Enumerate the widgets in the WidgetRepository for this installation
|
||||
* of Grist.
|
||||
*/
|
||||
getWidgets(): Promise<ICustomWidget[]>;
|
||||
|
||||
/**
|
||||
* Reload cached list of widgets, for testing purposes.
|
||||
*/
|
||||
testReloadWidgets(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +157,12 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
||||
public readonly orgs = Observable.create<Organization[]>(this, []);
|
||||
public readonly users = Observable.create<FullUser[]>(this, []);
|
||||
public readonly plugins: LocalPlugin[] = [];
|
||||
public readonly customWidgets = Observable.create<ICustomWidget[]|null>(this, null);
|
||||
private readonly _gristConfig?: GristLoadConfig;
|
||||
// Keep a list of available widgets, once requested, so we don't have to
|
||||
// keep reloading it. Downside: browser page will need reloading to pick
|
||||
// up new widgets - that seems ok.
|
||||
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
|
||||
|
||||
constructor(
|
||||
window: {gristConfig?: GristLoadConfig},
|
||||
@@ -153,6 +173,11 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
||||
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
|
||||
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
|
||||
this._gristConfig = window.gristConfig;
|
||||
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
|
||||
const widgets = await this.api.getWidgets();
|
||||
this.customWidgets.set(widgets);
|
||||
return widgets;
|
||||
});
|
||||
|
||||
// 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).
|
||||
@@ -175,6 +200,19 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
public async getWidgets(): Promise<ICustomWidget[]> {
|
||||
return this._widgets.get();
|
||||
}
|
||||
|
||||
public async testReloadWidgets() {
|
||||
console.log("testReloadWidgets");
|
||||
this._widgets.clear();
|
||||
this.customWidgets.set(null);
|
||||
console.log("testReloadWidgets cleared and nulled");
|
||||
const result = await this.getWidgets();
|
||||
console.log("testReloadWidgets got", {result});
|
||||
}
|
||||
|
||||
public getUntrustedContentOrigin() {
|
||||
if (G.window.isRunningUnderElectron) {
|
||||
// when loaded within webviews it is safe to serve plugin's content from the same domain
|
||||
|
||||
@@ -272,11 +272,24 @@ export interface CustomViewSectionDef {
|
||||
* The url.
|
||||
*/
|
||||
url: modelUtil.KoSaveableObservable<string|null>;
|
||||
/**
|
||||
* Custom widget information.
|
||||
/**
|
||||
* A widgetId, if available. Preferred to url.
|
||||
* For bundled custom widgets, it is important to refer
|
||||
* to them by something other than url, since url will
|
||||
* vary with deployment, and it should be possible to move
|
||||
* documents between deployments if they have compatible
|
||||
* widgets available.
|
||||
*/
|
||||
widgetDef: modelUtil.KoSaveableObservable<ICustomWidget|null>;
|
||||
widgetId: modelUtil.KoSaveableObservable<string|null>;
|
||||
/**
|
||||
* Custom widget information. This is a record of what was
|
||||
* in a custom widget manifest entry when the widget was
|
||||
* configured. Its contents should not be relied on too much.
|
||||
* In particular, any URL contained may come from an entirely
|
||||
* different installation of Grist.
|
||||
*/
|
||||
widgetDef: modelUtil.KoSaveableObservable<ICustomWidget|null>;
|
||||
/**
|
||||
* Custom widget options.
|
||||
*/
|
||||
widgetOptions: modelUtil.KoSaveableObservable<Record<string, any>|null>;
|
||||
@@ -363,6 +376,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
||||
this.customDef = {
|
||||
mode: customDefObj.prop('mode'),
|
||||
url: customDefObj.prop('url'),
|
||||
widgetId: customDefObj.prop('widgetId'),
|
||||
widgetDef: customDefObj.prop('widgetDef'),
|
||||
widgetOptions: customDefObj.prop('widgetOptions'),
|
||||
columnsMapping: customDefObj.prop('columnsMapping'),
|
||||
|
||||
@@ -16,7 +16,7 @@ import {textInput} from 'app/client/ui2018/editableLabel';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {cssOptionLabel, IOption, IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
|
||||
import {AccessLevel, ICustomWidget, isSatisfied} from 'app/common/CustomWidget';
|
||||
import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
|
||||
import {GristLoadConfig} from 'app/common/gristUrls';
|
||||
import {not, unwrap} from 'app/common/gutil';
|
||||
import {
|
||||
@@ -388,7 +388,7 @@ export class CustomSectionConfig extends Disposable {
|
||||
|
||||
protected _customSectionConfigurationConfig: CustomSectionConfigurationConfig;
|
||||
// Holds all available widget definitions.
|
||||
private _widgets: Observable<ICustomWidget[]>;
|
||||
private _widgets: Observable<ICustomWidget[]|null>;
|
||||
// Holds selected option (either custom string or a widgetId).
|
||||
private readonly _selectedId: Computed<string | null>;
|
||||
// Holds custom widget URL.
|
||||
@@ -413,14 +413,20 @@ export class CustomSectionConfig extends Disposable {
|
||||
this._canSelect = gristConfig.enableWidgetRepository ?? true;
|
||||
|
||||
// Array of available widgets - will be updated asynchronously.
|
||||
this._widgets = Observable.create(this, []);
|
||||
this._widgets = _gristDoc.app.topAppModel.customWidgets;
|
||||
this._getWidgets().catch(reportError);
|
||||
// Request for rest of the widgets.
|
||||
|
||||
// Selected value from the dropdown (contains widgetId or "custom" string for Custom URL)
|
||||
this._selectedId = Computed.create(this, use => {
|
||||
if (use(_section.customDef.widgetDef)) {
|
||||
return _section.customDef.widgetDef.peek()!.widgetId;
|
||||
// widgetId could be stored in one of two places, depending on
|
||||
// age of document.
|
||||
const widgetId = use(_section.customDef.widgetId) ||
|
||||
use(_section.customDef.widgetDef)?.widgetId;
|
||||
const pluginId = use(_section.customDef.pluginId);
|
||||
if (widgetId) {
|
||||
// selection id is "pluginId:widgetId"
|
||||
return (pluginId || '') + ':' + widgetId;
|
||||
}
|
||||
return CUSTOM_ID;
|
||||
});
|
||||
@@ -432,8 +438,11 @@ export class CustomSectionConfig extends Disposable {
|
||||
_section.customDef.renderAfterReady(false);
|
||||
// Clear url.
|
||||
_section.customDef.url(null);
|
||||
// Clear widget definition.
|
||||
// Clear widgetId
|
||||
_section.customDef.widgetId(null);
|
||||
_section.customDef.widgetDef(null);
|
||||
// Clear pluginId
|
||||
_section.customDef.pluginId('');
|
||||
// Reset access level to none.
|
||||
_section.customDef.access(AccessLevel.none);
|
||||
// Clear all saved options.
|
||||
@@ -447,14 +456,19 @@ export class CustomSectionConfig extends Disposable {
|
||||
});
|
||||
await _section.saveCustomDef();
|
||||
} else {
|
||||
const [pluginId, widgetId] = value?.split(':') || [];
|
||||
// Select Widget
|
||||
const selectedWidget = this._widgets.get().find(w => w.widgetId === value);
|
||||
const selectedWidget = matchWidget(this._widgets.get()||[], {
|
||||
widgetId,
|
||||
pluginId,
|
||||
});
|
||||
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) {
|
||||
if (_section.customDef.widgetId.peek() === widgetId &&
|
||||
_section.customDef.pluginId.peek() === pluginId) {
|
||||
return;
|
||||
}
|
||||
bundleChanges(() => {
|
||||
@@ -464,10 +478,19 @@ export class CustomSectionConfig extends Disposable {
|
||||
_section.customDef.access(AccessLevel.none);
|
||||
// When widget wants some access, set desired access level.
|
||||
this._desiredAccess.set(selectedWidget.accessLevel || AccessLevel.none);
|
||||
// Update widget definition.
|
||||
|
||||
// Keep a record of the original widget definition.
|
||||
// Don't rely on this much, since the document could
|
||||
// have moved installation since, and widgets could be
|
||||
// served from elsewhere.
|
||||
_section.customDef.widgetDef(selectedWidget);
|
||||
// Update widget URL.
|
||||
_section.customDef.url(selectedWidget.url);
|
||||
|
||||
// Update widgetId.
|
||||
_section.customDef.widgetId(selectedWidget.widgetId);
|
||||
// Update pluginId.
|
||||
_section.customDef.pluginId(selectedWidget.source?.pluginId || '');
|
||||
// Update widget URL. Leave blank when widgetId is set.
|
||||
_section.customDef.url(null);
|
||||
// Clear options.
|
||||
_section.customDef.widgetOptions(null);
|
||||
// Clear has custom configuration.
|
||||
@@ -486,6 +509,13 @@ export class CustomSectionConfig extends Disposable {
|
||||
this._url.onWrite(async newUrl => {
|
||||
bundleChanges(() => {
|
||||
_section.customDef.renderAfterReady(false);
|
||||
if (newUrl) {
|
||||
// When a URL is set explicitly, make sure widgetId/pluginId/widgetDef
|
||||
// is empty.
|
||||
_section.customDef.widgetId(null);
|
||||
_section.customDef.pluginId('');
|
||||
_section.customDef.widgetDef(null);
|
||||
}
|
||||
_section.customDef.url(newUrl);
|
||||
});
|
||||
await _section.saveCustomDef();
|
||||
@@ -521,7 +551,10 @@ export class CustomSectionConfig extends Disposable {
|
||||
// Options for the select-box (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})),
|
||||
...(use(this._widgets) || []).map(w => ({
|
||||
label: w.source?.name ? `${w.name} (${w.source.name})` : w.name,
|
||||
value: (w.source?.pluginId || '') + ':' + w.widgetId,
|
||||
})),
|
||||
]);
|
||||
function buildPrompt(level: AccessLevel|null) {
|
||||
if (!level) {
|
||||
@@ -557,7 +590,7 @@ export class CustomSectionConfig extends Disposable {
|
||||
testId('select')
|
||||
)
|
||||
: null,
|
||||
dom.maybe(isCustom && this.shouldRenderWidgetSelector(), () => [
|
||||
dom.maybe((use) => use(isCustom) && this.shouldRenderWidgetSelector(), () => [
|
||||
cssRow(
|
||||
cssTextInput(
|
||||
this._url,
|
||||
@@ -626,19 +659,7 @@ export class CustomSectionConfig extends Disposable {
|
||||
}
|
||||
|
||||
protected async _getWidgets() {
|
||||
const api = this._gristDoc.app.topAppModel.api;
|
||||
const widgets = await api.getWidgets();
|
||||
if (this.isDisposed()) { return; }
|
||||
|
||||
// Request for rest of the widgets.
|
||||
if (this._canSelect) {
|
||||
// From the start we will provide single widget definition
|
||||
// that was chosen previously.
|
||||
if (this._section.customDef.widgetDef.peek()) {
|
||||
widgets.push(this._section.customDef.widgetDef.peek()!);
|
||||
}
|
||||
}
|
||||
this._widgets.set(widgets);
|
||||
await this._gristDoc.app.topAppModel.getWidgets();
|
||||
}
|
||||
|
||||
private _accept() {
|
||||
|
||||
Reference in New Issue
Block a user