(core) custom widget appear as build-in widget

Summary: Added boilerplate code needed to create new wigets in "Add new" menu, that are wrapped around existing custom widgets. More details can be found here: https://grist.quip.com/larhAGRKyl6Z/Custom-widgets-in-Add-Widget-menu

Test Plan: nbowser tests added to verify if item in menu exits, if widget is rendered, and right side menu has widget selection and read access selection hided.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3994
This commit is contained in:
Jakub Serafin
2023-08-29 16:50:42 +02:00
parent b6a431dd58
commit 942fc96225
21 changed files with 474 additions and 182 deletions

View File

@@ -0,0 +1,31 @@
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";
//Abstract class for more future inheritances
abstract class CustomAttachedView extends CustomView {
public override create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
super.create(gristDoc, viewSectionModel);
void viewSectionModel.customDef.access.setAndSave(AccessLevel.full);
const widgetsApi = this.gristDoc.app.topAppModel.api;
widgetsApi.getWidgets().then(async result=>{
const widget = result.find(w=>w.name == this.getWidgetName());
if(widget) {
await this.customDef.url.setAndSave(widget.url);
}
}).catch(()=>{
//do nothing
});
}
protected abstract getWidgetName(): string;
}
export class CustomCalendarView extends CustomAttachedView {
protected getWidgetName(): string {
return "Calendar";
}
}

View File

@@ -1,10 +1,18 @@
import BaseView from 'app/client/components/BaseView';
import {Cursor} from 'app/client/components/Cursor';
import * as commands from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {ConfigNotifier, CustomSectionAPIImpl, GristDocAPIImpl, GristViewImpl,
MinimumLevel, RecordNotifier, TableNotifier, WidgetAPIImpl,
WidgetFrame} from 'app/client/components/WidgetFrame';
import {
ConfigNotifier,
CustomSectionAPIImpl,
GristDocAPIImpl,
GristViewImpl,
MinimumLevel,
RecordNotifier,
TableNotifier,
WidgetAPIImpl,
WidgetFrame
} from 'app/client/components/WidgetFrame';
import {CustomSectionElement, ViewProcess} from 'app/client/lib/CustomSectionElement';
import {Disposable} from 'app/client/lib/dispose';
import dom from 'app/client/lib/dom';
@@ -14,15 +22,16 @@ import {ViewSectionRec} from 'app/client/models/DocModel';
import {CustomViewSectionDef} from 'app/client/models/entities/ViewSectionRec';
import {UserError} from 'app/client/models/errors';
import {SortedRowSet} from 'app/client/models/rowset';
import {PluginInstance} from 'app/common/PluginInstance';
import {AccessLevel} from 'app/common/CustomWidget';
import {closeRegisteredMenu} from 'app/client/ui2018/menus';
import {AccessLevel} from 'app/common/CustomWidget';
import {PluginInstance} from 'app/common/PluginInstance';
import {getGristConfig} from 'app/common/urlUtils';
import {Events as BackboneEvents} from 'backbone';
import {dom as grains} from 'grainjs';
import * as ko from 'knockout';
import defaults = require('lodash/defaults');
/**
* CustomView components displays arbitrary html. There are two modes available, in the "url" mode
* the content is hosted by a third-party (for instance a github page), as opposed to the "plugin"
@@ -60,7 +69,7 @@ export class CustomView extends Disposable {
protected gristDoc: GristDoc;
protected cursor: Cursor;
private _customDef: CustomViewSectionDef;
protected customDef: CustomViewSectionDef;
// state of the component
private _foundPlugin: ko.Observable<boolean>;
@@ -70,14 +79,12 @@ export class CustomView extends Disposable {
private _pluginInstance: PluginInstance|undefined;
private _frame: WidgetFrame; // plugin frame (holding external page)
private _emptyWidgetPage: string;
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, { 'addNewRow': true });
this._customDef = this.viewSection.customDef;
this._emptyWidgetPage = new URL("custom-widget.html", getGristConfig().homeUrl!).href;
this.customDef = this.viewSection.customDef;
this.autoDisposeCallback(() => {
if (this._customSection) {
@@ -89,27 +96,31 @@ export class CustomView extends Disposable {
// Ensure that selecting another section in same plugin update the view.
this._foundSection.extend({notify: 'always'});
this.autoDispose(this._customDef.pluginId.subscribe(this._updatePluginInstance, this));
this.autoDispose(this._customDef.sectionId.subscribe(this._updateCustomSection, this));
this.autoDispose(this.customDef.pluginId.subscribe(this._updatePluginInstance, this));
this.autoDispose(this.customDef.sectionId.subscribe(this._updateCustomSection, this));
this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.hasFocus));
this.viewPane = this.autoDispose(this._buildDom());
this._updatePluginInstance();
}
public async triggerPrint() {
if (!this.isDisposed() && this._frame) {
return await this._frame.callRemote('print');
}
}
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.
*/
private _updatePluginInstance() {
const pluginId = this._customDef.pluginId();
const pluginId = this.customDef.pluginId();
this._pluginInstance = this.gristDoc.docPluginManager.pluginsList.find(p => p.definition.id === pluginId);
if (this._pluginInstance) {
@@ -129,7 +140,7 @@ export class CustomView extends Disposable {
if (!this._pluginInstance) { return; }
const sectionId = this._customDef.sectionId();
const sectionId = this.customDef.sectionId();
this._customSection = CustomSectionElement.find(this._pluginInstance, sectionId);
if (this._customSection) {
@@ -142,8 +153,8 @@ export class CustomView extends Disposable {
}
private _buildDom() {
const {mode, url, access} = this._customDef;
const showPlugin = ko.pureComputed(() => this._customDef.mode() === "plugin");
const {mode, url, access} = this.customDef;
const showPlugin = ko.pureComputed(() => this.customDef.mode() === "plugin");
// When both plugin and section are not found, let's show only plugin notification.
const showPluginNotification = ko.pureComputed(() => showPlugin() && !this._foundPlugin());
@@ -161,12 +172,12 @@ export class CustomView extends Disposable {
kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) =>
_mode === "url" ? this._buildIFrame(_url, (_access || AccessLevel.none) as AccessLevel) : null),
kd.maybe(showPluginNotification, () => buildNotification('Plugin ',
dom('strong', kd.text(this._customDef.pluginId)), ' was not found',
dom('strong', kd.text(this.customDef.pluginId)), ' was not found',
dom.testId('customView_notification_plugin')
)),
kd.maybe(showSectionNotification, () => buildNotification('Section ',
dom('strong', kd.text(this._customDef.sectionId)), ' was not found in plugin ',
dom('strong', kd.text(this._customDef.pluginId)),
dom('strong', kd.text(this.customDef.sectionId)), ' was not found in plugin ',
dom('strong', kd.text(this.customDef.pluginId)),
dom.testId('customView_notification_section')
)),
// When showPluginContent() is true then _foundSection() is also and _customSection is not
@@ -184,7 +195,7 @@ export class CustomView extends Disposable {
private _buildIFrame(baseUrl: string, access: AccessLevel) {
return grains.create(WidgetFrame, {
url: baseUrl || this._emptyWidgetPage,
url: baseUrl || this.getEmptyWidgetPage(),
access,
readonly: this.gristDoc.isReadonly.get(),
configure: (frame) => {

View File

@@ -47,7 +47,7 @@ import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkFromId, selectBy} from 'app/client/ui/selectBy';
import {WebhookPage} from 'app/client/ui/WebhookPage';
import {startWelcomeTour} from 'app/client/ui/WelcomeTour';
import {IWidgetType} from 'app/client/ui/widgetTypes';
import {IWidgetType} from 'app/common/widgetTypes';
import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer';
import {isNarrowScreen, mediaSmall, mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList';

View File

@@ -1,12 +1,15 @@
import BaseView from 'app/client/components/BaseView';
import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom';
import {ChartView} from 'app/client/components/ChartView';
import * as commands from 'app/client/components/commands';
import {CustomCalendarView} from "app/client/components/CustomCalendarView";
import {CustomView} from 'app/client/components/CustomView';
import * as DetailView from 'app/client/components/DetailView';
import * as GridView from 'app/client/components/GridView';
import {GristDoc} from 'app/client/components/GristDoc';
import {BoxSpec, Layout} from 'app/client/components/Layout';
import {LayoutEditor} from 'app/client/components/LayoutEditor';
import {LayoutTray} from 'app/client/components/LayoutTray';
import {printViewSection} from 'app/client/components/Printing';
import {Delay} from 'app/client/lib/Delay';
import {createObsArray} from 'app/client/lib/koArrayWrap';
@@ -15,14 +18,23 @@ import {reportError} from 'app/client/models/errors';
import {isNarrowScreen, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {LayoutTray} from 'app/client/components/LayoutTray';
import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom';
import {mod} from 'app/common/gutil';
import {
Computed,
computedArray,
Disposable,
dom,
fromKo,
Holder,
IDomComponent,
MultiHolder,
Observable,
styled,
subscribe
} from 'grainjs';
import * as ko from 'knockout';
import * as _ from 'underscore';
import debounce from 'lodash/debounce';
import {Computed, computedArray, Disposable, dom, fromKo, Holder,
IDomComponent, MultiHolder, Observable, styled, subscribe} from 'grainjs';
import * as _ from 'underscore';
// tslint:disable:no-console
@@ -32,6 +44,7 @@ const viewSectionTypes: {[key: string]: any} = {
chart: ChartView,
single: DetailView,
custom: CustomView,
'custom.calendar': CustomCalendarView,
};
function getInstanceConstructor(parentKey: string) {

View File

@@ -89,7 +89,6 @@ export class WidgetFrame extends DisposableWithEvents {
// Call custom configuration handler.
_options.configure?.(this);
}
/**
* Attach an EventSource with desired access level.
*/

View File

@@ -1,16 +1,16 @@
import BaseView from 'app/client/components/BaseView';
import {GristDoc} from 'app/client/components/GristDoc';
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {makeT} from 'app/client/lib/localization';
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {filterBar} from 'app/client/ui/FilterBar';
import {cssIcon} from 'app/client/ui/RightPanelStyles';
import {makeCollapsedLayoutMenu} from 'app/client/ui/ViewLayoutMenu';
import {cssDotsIconWrapper, cssMenu, viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
import {buildWidgetTitle} from 'app/client/ui/WidgetTitle';
import {getWidgetTypes} from 'app/client/ui/widgetTypes';
import {colors, isNarrowScreenObs, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu} from 'app/client/ui2018/menus';
import {getWidgetTypes} from "app/client/ui/widgetTypesMap";
import {Computed, dom, DomElementArg, Observable, styled} from 'grainjs';
import {defaultMenuOptions} from 'popweasel';