bundling experiments (WIP)

Looking at ways to bundle custom widgets with Grist. WIP,
experimental, everything will need rewrite.
This commit is contained in:
Paul Fitzpatrick
2023-10-03 15:20:57 -04:00
parent 97a84ce6ee
commit fd1734de69
22 changed files with 734 additions and 155 deletions

View File

@@ -1,11 +1,13 @@
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 {AccessLevel} from "app/common/CustomWidget";
// import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec";
import { CustomView, CustomViewSettings } from "app/client/components/CustomView";
import { AccessLevel } from "app/common/CustomWidget";
// import {GristDoc} from "app/client/components/GristDoc";
// import {reportError} from 'app/client/models/errors';
//Abstract class for more future inheritances
abstract class CustomAttachedView extends CustomView {
// abstract class CustomAttachedView extends CustomView {
/*
public override create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
super.create(gristDoc, viewSectionModel);
if (viewSectionModel.customDef.access.peek() !== AccessLevel.full) {
@@ -18,7 +20,7 @@ abstract class CustomAttachedView extends CustomView {
});
}
const widgetsApi = this.gristDoc.app.topAppModel.api;
const widgetsApi = this.gristDoc.app.topAppModel;
widgetsApi.getWidgets().then(async result=>{
const widget = result.find(w=>w.name == this.getWidgetName());
if (widget && this.customDef.url.peek() !== widget.url) {
@@ -34,13 +36,17 @@ abstract class CustomAttachedView extends CustomView {
}
});
}
*/
protected abstract getWidgetName(): string;
// protected abstract getWidgetName(): string;
}
// }
export class CustomCalendarView extends CustomAttachedView {
protected getWidgetName(): string {
return "Calendar";
export class CustomCalendarView extends CustomView {
protected getInitialSettings(): CustomViewSettings {
return {
widgetId: '@gristlabs/widget-calendar',
accessLevel: AccessLevel.full,
};
}
}

View File

@@ -32,6 +32,10 @@ import {dom as grains} from 'grainjs';
import * as ko from 'knockout';
import defaults = require('lodash/defaults');
export interface CustomViewSettings {
widgetId?: string;
accessLevel?: AccessLevel;
}
/**
* CustomView components displays arbitrary html. There are two modes available, in the "url" mode
@@ -81,11 +85,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) {
@@ -103,8 +106,43 @@ export class CustomView extends Disposable {
this.viewPane = this.autoDispose(this._buildDom());
this._updatePluginInstance();
this.dealWithBundledWidgets(gristDoc, viewSectionModel);
}
public dealWithBundledWidgets(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
const settings = this.getInitialSettings();
console.log("dealWith!", {settings});
if (!settings.widgetId) { return; }
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;
widgetsApi.getWidgets().then(async result=>{
const widget = result.find(w => w.widgetId === settings.widgetId);
console.log("FOUND", {widget});
if (widget && this.customDef.widgetId.peek() !== widget.widgetId) {
console.log("SET!!");
await this.customDef.widgetId.setAndSave(widget.widgetId);
await this.customDef.pluginId.setAndSave(widget.fromPlugin||'');
}
}).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.
}
});
}
public async triggerPrint() {
if (!this.isDisposed() && this._frame) {
@@ -112,9 +150,14 @@ export class CustomView extends Disposable {
}
}
protected getInitialSettings(): 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.
@@ -154,13 +197,16 @@ export class CustomView extends Disposable {
}
private _buildDom() {
const {mode, url, access, renderAfterReady} = this.customDef;
const {mode, url, access, renderAfterReady, widgetId, pluginId} = this.customDef;
const showPlugin = ko.pureComputed(() => this.customDef.mode() === "plugin");
const showAfterReady = () => {
// The empty widget page calls `grist.ready()`.
// Pending: URLs set now only when user actually enters a URL,
// so this could be breaking pages without grist.ready() call
// added to manifests.
if (!url()) { return true; }
return this.customDef.widgetDef()?.renderAfterReady ?? renderAfterReady();
return renderAfterReady();
};
// When both plugin and section are not found, let's show only plugin notification.
@@ -176,12 +222,14 @@ export class CustomView extends Disposable {
dom.autoDispose(showSectionNotification),
dom.autoDispose(showPluginContent),
// todo: should display content in webview when running electron
kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) =>
kd.scope(() => [mode(), url(), access(), widgetId(), pluginId()], ([_mode, _url, _access, _widgetId, _pluginId]: string[]) =>
_mode === "url" ?
this._buildIFrame({
baseUrl: _url,
access: (_access as AccessLevel || AccessLevel.none),
showAfterReady: showAfterReady(),
widgetId: _widgetId,
pluginId: _pluginId,
})
: null
),
@@ -211,10 +259,15 @@ 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;
return grains.create(WidgetFrame, {
url: baseUrl || this.getEmptyWidgetPage(),
widgetId,
pluginId,
emptyUrl: this.getEmptyWidgetPage(),
access,
readonly: this.gristDoc.isReadonly.get(),
showAfterReady,
@@ -265,7 +318,8 @@ export class CustomView extends Disposable {
}
// allow menus to close if any
closeRegisteredMenu();
})
}),
gristDoc: this.gristDoc,
});
}

View File

@@ -6,7 +6,7 @@ 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 {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';
@@ -19,6 +19,7 @@ import noop = require('lodash/noop');
import debounce = require('lodash/debounce');
import isEqual = require('lodash/isEqual');
import flatMap = require('lodash/flatMap');
import { reportError } from '../models/errors';
const testId = makeTestId('test-custom-widget-');
@@ -43,6 +44,9 @@ export interface WidgetFrameOptions {
* Url of external page. Iframe is rebuild each time the URL changes.
*/
url: string;
widgetId?: string|null;
pluginId?: string;
emptyUrl: string;
/**
* Assigned access level. Iframe is rebuild each time access level is changed.
*/
@@ -73,6 +77,8 @@ export interface WidgetFrameOptions {
* Optional handler to modify the iframe.
*/
onElem?: (iframe: HTMLIFrameElement) => void;
gristDoc: GristDoc;
}
/**
@@ -87,6 +93,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);
public readonly _widgets = Observable.create<ICustomWidget[]>(this, []);
constructor(private _options: WidgetFrameOptions) {
super();
@@ -113,7 +120,10 @@ export class WidgetFrame extends DisposableWithEvents {
// Call custom configuration handler.
_options.configure?.(this);
this._fetchWidgets().catch(reportError);
}
/**
* Attach an EventSource with desired access level.
*/
@@ -167,6 +177,23 @@ 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._widgets))),
{
...hooks.iframeAttributes,
},
testId('ready', this._readyCalled),
))
);
}
private _getUrl(widgets: ICustomWidget[]): string {
// Append access level to query string.
const urlWithAccess = (url: string) => {
if (!url) {
@@ -177,19 +204,20 @@ export class WidgetFrame extends DisposableWithEvents {
urlObj.searchParams.append('readonly', String(this._options.readonly));
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 {widgetId, pluginId} = this._options;
let url = this._options.url;
if (widgetId) {
console.log("Iframe match starting");
const widget = matchWidget(widgets, {widgetId, pluginId});
console.log("Iframe match done");
if (widget) {
url = widget.url;
} else {
return 'about:blank';
}
}
const fullUrl = urlWithAccess(url);
return fullUrl;
}
private _onMessage(event: MessageEvent) {
@@ -216,6 +244,14 @@ export class WidgetFrame extends DisposableWithEvents {
this._rpc.receiveMessage(event.data);
}
}
private async _fetchWidgets() {
if (this.isDisposed()) { return; }
const widgets = await this._options.gristDoc.app.topAppModel.getWidgets();
if (this.isDisposed()) { return; }
this._widgets.set(widgets);
console.log("SAVED", {widgets});
}
}
const throwError = (access: AccessLevel) => {