(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
pull/662/head
Jakub Serafin 10 months ago
parent b6a431dd58
commit 942fc96225

@ -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";
}
}

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

@ -47,7 +47,7 @@ import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkFromId, selectBy} from 'app/client/ui/selectBy'; import {linkFromId, selectBy} from 'app/client/ui/selectBy';
import {WebhookPage} from 'app/client/ui/WebhookPage'; import {WebhookPage} from 'app/client/ui/WebhookPage';
import {startWelcomeTour} from 'app/client/ui/WelcomeTour'; 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 {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer';
import {isNarrowScreen, mediaSmall, mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars'; import {isNarrowScreen, mediaSmall, mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList'; import {IconName} from 'app/client/ui2018/IconList';

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

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

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

@ -1,6 +1,7 @@
import BaseView from 'app/client/components/BaseView'; import BaseView from 'app/client/components/BaseView';
import {LinkingState} from 'app/client/components/LinkingState'; import {LinkingState} from 'app/client/components/LinkingState';
import {KoArray} from 'app/client/lib/koArray'; import {KoArray} from 'app/client/lib/koArray';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
import { import {
ColumnRec, ColumnRec,
DocModel, DocModel,
@ -13,18 +14,17 @@ import {
ViewFieldRec, ViewFieldRec,
ViewRec ViewRec
} from 'app/client/models/DocModel'; } from 'app/client/models/DocModel';
import {BEHAVIOR} from 'app/client/models/entities/ColumnRec';
import * as modelUtil from 'app/client/models/modelUtil'; import * as modelUtil from 'app/client/models/modelUtil';
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
import {LinkConfig} from 'app/client/ui/selectBy'; import {LinkConfig} from 'app/client/ui/selectBy';
import {getWidgetTypes} from 'app/client/ui/widgetTypes'; import {getWidgetTypes} from "app/client/ui/widgetTypesMap";
import {FilterColValues} from "app/common/ActiveDocAPI"; import {FilterColValues} from "app/common/ActiveDocAPI";
import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget'; import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget';
import {UserAction} from 'app/common/DocActions'; import {UserAction} from 'app/common/DocActions';
import {arrayRepeat} from 'app/common/gutil'; import {arrayRepeat} from 'app/common/gutil';
import {Sort} from 'app/common/SortSpec'; import {Sort} from 'app/common/SortSpec';
import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI'; import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
import {BEHAVIOR} from 'app/client/models/entities/ColumnRec';
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
import {CursorPos, UIRowId} from 'app/plugin/GristAPI'; import {CursorPos, UIRowId} from 'app/plugin/GristAPI';
import {Computed, Holder, Observable} from 'grainjs'; import {Computed, Holder, Observable} from 'grainjs';
import * as ko from 'knockout'; import * as ko from 'knockout';

@ -1,6 +1,6 @@
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import {localStorageBoolObs, localStorageJsonObs} from 'app/client/lib/localStorageObs';
import {Observable} from 'grainjs'; import {Observable} from 'grainjs';
/** /**
@ -24,3 +24,12 @@ export function HAS_FORMULA_ASSISTANT() {
export function WHICH_FORMULA_ASSISTANT() { export function WHICH_FORMULA_ASSISTANT() {
return getGristConfig().assistantService; return getGristConfig().assistantService;
} }
export function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> {
const G = getBrowserGlobals('document', 'window');
if (!G.window.PERMITTED_CUSTOM_WIDGETS) {
G.window.PERMITTED_CUSTOM_WIDGETS =
localStorageJsonObs('PERMITTED_CUSTOM_WIDGETS', getGristConfig().permittedCustomWidgets || []);
}
return G.window.PERMITTED_CUSTOM_WIDGETS;
}

@ -1,6 +1,7 @@
import {allCommands} from 'app/client/components/commands'; import {allCommands} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import * as kf from 'app/client/lib/koForm'; import * as kf from 'app/client/lib/koForm';
import {makeT} from 'app/client/lib/localization';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap'; import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors'; import {reportError} from 'app/client/models/errors';
@ -16,10 +17,19 @@ import {cssLink} from 'app/client/ui2018/links';
import {IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus'; import {IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
import {AccessLevel, ICustomWidget, isSatisfied} from 'app/common/CustomWidget'; import {AccessLevel, ICustomWidget, isSatisfied} from 'app/common/CustomWidget';
import {GristLoadConfig} from 'app/common/gristUrls'; import {GristLoadConfig} from 'app/common/gristUrls';
import {nativeCompare, unwrap} from 'app/common/gutil'; import {unwrap} from 'app/common/gutil';
import {bundleChanges, Computed, Disposable, dom, fromKo, makeTestId, import {
MultiHolder, Observable, styled, UseCBOwner} from 'grainjs'; bundleChanges,
import {makeT} from 'app/client/lib/localization'; Computed,
Disposable,
dom,
fromKo,
makeTestId,
MultiHolder,
Observable,
styled,
UseCBOwner
} from 'grainjs';
const t = makeT('CustomSectionConfig'); const t = makeT('CustomSectionConfig');
@ -165,8 +175,7 @@ class ColumnListPicker extends Disposable {
const columns = use(this._section.columns).filter(this._typeFilter(use)); const columns = use(this._section.columns).filter(this._typeFilter(use));
const columnMap = new Map(columns.map(c => [c.id.peek(), c])); const columnMap = new Map(columns.map(c => [c.id.peek(), c]));
// Remove any columns that are no longer there. // Remove any columns that are no longer there.
const selectedFields = selectedRefs.map(s => columnMap.get(s)!).filter(c => Boolean(c)); return selectedRefs.map(s => columnMap.get(s)!).filter(c => Boolean(c));
return selectedFields;
} }
private _renderItem(use: UseCBOwner, field: ColumnRec): any { private _renderItem(use: UseCBOwner, field: ColumnRec): any {
return cssFieldEntry( return cssFieldEntry(
@ -221,27 +230,82 @@ class ColumnListPicker extends Disposable {
} }
} }
class CustomSectionConfigurationConfig extends Disposable{
// Does widget has custom configuration.
private readonly _hasConfiguration: Computed<boolean>;
constructor(private _section: ViewSectionRec) {
super();
this._hasConfiguration = Computed.create(this, use => use(_section.hasCustomOptions));
}
public buildDom() {
// Show prompt, when desired access level is different from actual one.
return dom(
'div',
dom.maybe(this._hasConfiguration, () =>
cssSection(
textButton(
t("Open configuration"),
dom.on('click', () => this._openConfiguration()),
testId('open-configuration')
)
)
),
dom.maybeOwned(use => use(this._section.columnsToMap), (owner, columns) => {
const createObs = (column: ColumnToMapImpl) => {
const obs = Computed.create(owner, use => {
const savedDefinition = use(this._section.customDef.columnsMapping) || {};
return savedDefinition[column.name];
});
obs.onWrite(async (value) => {
const savedDefinition = this._section.customDef.columnsMapping.peek() || {};
savedDefinition[column.name] = value;
await this._section.customDef.columnsMapping.setAndSave(savedDefinition);
});
return obs;
};
// Create observables for all columns to pick.
const mappings = columns.map(c => new ColumnToMapImpl(c)).map((column) => ({
value: createObs(column),
column
}));
return [
...mappings.map(m => m.column.allowMultiple
? dom.create(ColumnListPicker, m.value, m.column, this._section)
: dom.create(ColumnPicker, m.value, m.column, this._section))
];
})
);
}
private _openConfiguration(): void {
allCommands.openWidgetConfiguration.run();
}
}
export class CustomSectionConfig extends Disposable { export class CustomSectionConfig extends Disposable {
protected _customSectionConfigurationConfig: CustomSectionConfigurationConfig;
// Holds all available widget definitions. // Holds all available widget definitions.
private _widgets: Observable<ICustomWidget[]>; private _widgets: Observable<ICustomWidget[]>;
// Holds selected option (either custom string or a widgetId). // Holds selected option (either custom string or a widgetId).
private _selectedId: Computed<string | null>; private readonly _selectedId: Computed<string | null>;
// Holds custom widget URL. // Holds custom widget URL.
private _url: Computed<string>; private readonly _url: Computed<string>;
// Enable or disable widget repository. // Enable or disable widget repository.
private _canSelect = true; private readonly _canSelect: boolean = true;
// When widget is changed, it sets its desired access level. We will prompt // When widget is changed, it sets its desired access level. We will prompt
// user to approve or reject it. // user to approve or reject it.
private _desiredAccess: Observable<AccessLevel|null>; private readonly _desiredAccess: Observable<AccessLevel|null>;
// Current access level (stored inside a section). // Current access level (stored inside a section).
private _currentAccess: Computed<AccessLevel>; private readonly _currentAccess: Computed<AccessLevel>;
// Does widget has custom configuration.
private _hasConfiguration: Computed<boolean>;
constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {
super();
const api = _gristDoc.app.topAppModel.api;
constructor(protected _section: ViewSectionRec, private _gristDoc: GristDoc) {
super();
this._customSectionConfigurationConfig = new CustomSectionConfigurationConfig(_section);
// Test if we can offer widget list. // Test if we can offer widget list.
const gristConfig: GristLoadConfig = (window as any).gristConfig || {}; const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
@ -249,29 +313,8 @@ export class CustomSectionConfig extends Disposable {
// Array of available widgets - will be updated asynchronously. // Array of available widgets - will be updated asynchronously.
this._widgets = Observable.create(this, []); this._widgets = Observable.create(this, []);
this._getWidgets().catch(reportError);
if (this._canSelect) { // Request for rest of the widgets.
// 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(reportError);
}
// Selected value from the dropdown (contains widgetId or "custom" string for Custom URL) // Selected value from the dropdown (contains widgetId or "custom" string for Custom URL)
this._selectedId = Computed.create(this, use => { this._selectedId = Computed.create(this, use => {
@ -350,8 +393,6 @@ export class CustomSectionConfig extends Disposable {
// Clear intermediate state when section changes. // Clear intermediate state when section changes.
this.autoDispose(_section.id.subscribe(() => this._reject())); this.autoDispose(_section.id.subscribe(() => this._reject()));
this._hasConfiguration = Computed.create(this, use => use(_section.hasCustomOptions));
} }
public buildDom() { public buildDom() {
@ -395,16 +436,17 @@ export class CustomSectionConfig extends Disposable {
return dom( return dom(
'div', 'div',
dom.autoDispose(holder), dom.autoDispose(holder),
this.shouldRenderWidgetSelector() &&
this._canSelect this._canSelect
? cssRow( ? cssRow(
select(this._selectedId, options, { select(this._selectedId, options, {
defaultLabel: t("Select Custom Widget"), defaultLabel: t("Select Custom Widget"),
menuCssClass: cssMenu.className, menuCssClass: cssMenu.className,
}), }),
testId('select') testId('select')
) )
: null, : null,
dom.maybe(isCustom, () => [ dom.maybe(isCustom && this.shouldRenderWidgetSelector(), () => [
cssRow( cssRow(
cssTextInput( cssTextInput(
this._url, this._url,
@ -452,15 +494,15 @@ export class CustomSectionConfig extends Disposable {
cssRow(select(this._currentAccess, levels), testId('access')), cssRow(select(this._currentAccess, levels), testId('access')),
] ]
), ),
dom.maybe(this._hasConfiguration, () => cssSection(
cssSection( cssLink(
textButton( dom.attr('href', 'https://support.getgrist.com/widget-custom'),
t("Open configuration"), dom.attr('target', '_blank'),
dom.on('click', () => this._openConfiguration()), t("Learn more about custom widgets")
testId('open-configuration')
)
) )
), ),
cssSeparator(),
this._customSectionConfigurationConfig.buildDom(),
cssSection( cssSection(
cssLink( cssLink(
dom.attr('href', 'https://support.getgrist.com/widget-custom'), dom.attr('href', 'https://support.getgrist.com/widget-custom'),
@ -468,38 +510,29 @@ export class CustomSectionConfig extends Disposable {
t("Learn more about custom widgets") t("Learn more about custom widgets")
) )
), ),
dom.maybeOwned(use => use(this._section.columnsToMap), (owner, columns) => {
const createObs = (column: ColumnToMapImpl) => {
const obs = Computed.create(owner, use => {
const savedDefinition = use(this._section.customDef.columnsMapping) || {};
return savedDefinition[column.name];
});
obs.onWrite(async (value) => {
const savedDefinition = this._section.customDef.columnsMapping.peek() || {};
savedDefinition[column.name] = value;
await this._section.customDef.columnsMapping.setAndSave(savedDefinition);
});
return obs;
};
// Create observables for all columns to pick.
const mappings = columns.map(c => new ColumnToMapImpl(c)).map((column) => ({
value: createObs(column),
column
}));
return [
cssSeparator(),
...mappings.map(m => m.column.allowMultiple
? dom.create(ColumnListPicker, m.value, m.column, this._section)
: dom.create(ColumnPicker, m.value, m.column, this._section))
];
})
); );
} }
private _openConfiguration(): void { protected shouldRenderWidgetSelector(): boolean {
allCommands.openWidgetConfiguration.run(); return true;
}
protected async _getWidgets() {
const api = this._gristDoc.app.topAppModel.api;
const wigets = await api.getWidgets();
// 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()) {
wigets.push(this._section.customDef.widgetDef.peek()!);
}
}
this._widgets.set(wigets);
} }
private _accept() { private _accept() {
if (this._desiredAccess.get()) { if (this._desiredAccess.get()) {
this._currentAccess.set(this._desiredAccess.get()!); this._currentAccess.set(this._desiredAccess.get()!);
@ -512,7 +545,6 @@ export class CustomSectionConfig extends Disposable {
} }
} }
const cssWarningWrapper = styled('div', ` const cssWarningWrapper = styled('div', `
padding-left: 8px; padding-left: 8px;
padding-top: 6px; padding-top: 6px;

@ -1,23 +1,37 @@
import { BehavioralPromptsManager } from 'app/client/components/BehavioralPromptsManager'; import {BehavioralPromptsManager} from 'app/client/components/BehavioralPromptsManager';
import { GristDoc } from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import { makeT } from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import { reportError } from 'app/client/models/AppModel'; import {reportError} from 'app/client/models/AppModel';
import { ColumnRec, TableRec, ViewSectionRec } from 'app/client/models/DocModel'; import {ColumnRec, TableRec, ViewSectionRec} from 'app/client/models/DocModel';
import { GristTooltips } from 'app/client/ui/GristTooltips'; import {PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features";
import { linkId, NoLink } from 'app/client/ui/selectBy'; import {GristTooltips} from 'app/client/ui/GristTooltips';
import { withInfoTooltip } from 'app/client/ui/tooltips'; import {linkId, NoLink} from 'app/client/ui/selectBy';
import { getWidgetTypes, IWidgetType } from 'app/client/ui/widgetTypes'; import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
import { bigPrimaryButton } from "app/client/ui2018/buttons"; import {getWidgetTypes} from "app/client/ui/widgetTypesMap";
import { overflowTooltip } from "app/client/ui/tooltips"; import {bigPrimaryButton} from "app/client/ui2018/buttons";
import { theme, vars } from "app/client/ui2018/cssVars"; import {theme, vars} from "app/client/ui2018/cssVars";
import { icon } from "app/client/ui2018/icons"; import {icon} from "app/client/ui2018/icons";
import { spinnerModal } from 'app/client/ui2018/modals'; import {spinnerModal} from 'app/client/ui2018/modals';
import { isLongerThan, nativeCompare } from "app/common/gutil"; import {isLongerThan, nativeCompare} from "app/common/gutil";
import { computed, Computed, Disposable, dom, domComputed, DomElementArg, fromKo, IOption, select} from "grainjs"; import {IAttachedCustomWidget, IWidgetType} from 'app/common/widgetTypes';
import { makeTestId, Observable, onKeyDown, styled} from "grainjs"; import {
import without = require('lodash/without'); computed,
Computed,
Disposable,
dom,
domComputed,
DomElementArg,
fromKo,
IOption,
makeTestId,
Observable,
onKeyDown,
select,
styled
} from "grainjs";
import Popper from 'popper.js'; import Popper from 'popper.js';
import { IOpenController, popupOpen, setPopupToCreateDom } from 'popweasel'; import {IOpenController, popupOpen, setPopupToCreateDom} from 'popweasel';
import without = require('lodash/without');
const t = makeT('PageWidgetPicker'); const t = makeT('PageWidgetPicker');
@ -79,7 +93,7 @@ const testId = makeTestId('test-wselect-');
// compatible types given the tableId and whether user is creating a new page or not. // compatible types given the tableId and whether user is creating a new page or not.
function getCompatibleTypes(tableId: TableId, isNewPage: boolean|undefined): IWidgetType[] { function getCompatibleTypes(tableId: TableId, isNewPage: boolean|undefined): IWidgetType[] {
if (tableId !== 'New Table') { if (tableId !== 'New Table') {
return ['record', 'single', 'detail', 'chart', 'custom']; return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar'];
} else if (isNewPage) { } else if (isNewPage) {
// New view + new table means we'll be switching to the primary view. // New view + new table means we'll be switching to the primary view.
return ['record']; return ['record'];
@ -240,9 +254,15 @@ export interface ISelectOptions {
selectBy?: (val: IPageWidget) => Array<IOption<string>>; selectBy?: (val: IPageWidget) => Array<IOption<string>>;
} }
const registeredCustomWidgets: IAttachedCustomWidget[] = ['custom.calendar'];
const permittedCustomWidgets: IAttachedCustomWidget[] = PERMITTED_CUSTOM_WIDGETS().get().map((widget) =>
widget as IAttachedCustomWidget)??[];
// the list of widget types in the order they should be listed by the widget. // the list of widget types in the order they should be listed by the widget.
const finalListOfCustomWidgetToShow = permittedCustomWidgets.filter(a=>
registeredCustomWidgets.includes(a));
const sectionTypes: IWidgetType[] = [ const sectionTypes: IWidgetType[] = [
'record', 'single', 'detail', 'chart', 'custom' 'record', 'single', 'detail', 'chart', ...finalListOfCustomWidgetToShow, 'custom'
]; ];

@ -0,0 +1,23 @@
import {GristDoc} from "../components/GristDoc";
import {ViewSectionRec} from "../models/entities/ViewSectionRec";
import {CustomSectionConfig} from "./CustomSectionConfig";
export class PredefinedCustomSectionConfig extends CustomSectionConfig {
constructor(section: ViewSectionRec, gristDoc: GristDoc) {
super(section, gristDoc);
}
public buildDom() {
return this._customSectionConfigurationConfig.buildDom();
}
protected shouldRenderWidgetSelector(): boolean {
return false;
}
protected async _getWidgets(): Promise<void> {
// Do nothing.
}
}

@ -24,15 +24,16 @@ import {makeT} from 'app/client/lib/localization';
import {createSessionObs} from 'app/client/lib/sessionObs'; import {createSessionObs} from 'app/client/lib/sessionObs';
import {reportError} from 'app/client/models/AppModel'; import {reportError} from 'app/client/models/AppModel';
import {ViewSectionRec} from 'app/client/models/DocModel'; import {ViewSectionRec} from 'app/client/models/DocModel';
import {GridOptions} from 'app/client/ui/GridOptions';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkId, selectBy} from 'app/client/ui/selectBy';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig'; import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
import {BuildEditorOptions} from 'app/client/ui/FieldConfig'; import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
import {GridOptions} from 'app/client/ui/GridOptions';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSectionConfig";
import {cssLabel} from 'app/client/ui/RightPanelStyles'; import {cssLabel} from 'app/client/ui/RightPanelStyles';
import {linkId, selectBy} from 'app/client/ui/selectBy';
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig'; import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
import {IWidgetType, widgetTypes} from 'app/client/ui/widgetTypes'; import {widgetTypesMap} from "app/client/ui/widgetTypesMap";
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {testId, theme, vars} from 'app/client/ui2018/cssVars'; import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {textInput} from 'app/client/ui2018/editableLabel'; import {textInput} from 'app/client/ui2018/editableLabel';
@ -41,9 +42,22 @@ import {icon} from 'app/client/ui2018/icons';
import {select} from 'app/client/ui2018/menus'; import {select} from 'app/client/ui2018/menus';
import {FieldBuilder} from 'app/client/widgets/FieldBuilder'; import {FieldBuilder} from 'app/client/widgets/FieldBuilder';
import {StringUnion} from 'app/common/StringUnion'; import {StringUnion} from 'app/common/StringUnion';
import {bundleChanges, Computed, Disposable, dom, domComputed, DomContents, import {IWidgetType} from 'app/common/widgetTypes';
DomElementArg, DomElementMethod, IDomComponent} from 'grainjs'; import {
import {MultiHolder, Observable, styled, subscribe} from 'grainjs'; bundleChanges,
Computed,
Disposable,
dom,
domComputed,
DomContents,
DomElementArg,
DomElementMethod,
IDomComponent,
MultiHolder,
Observable,
styled,
subscribe
} from 'grainjs';
import * as ko from 'knockout'; import * as ko from 'knockout';
const t = makeT('RightPanel'); const t = makeT('RightPanel');
@ -170,7 +184,7 @@ export class RightPanel extends Disposable {
private _buildStandardHeader() { private _buildStandardHeader() {
return dom.maybe(this._pageWidgetType, (type) => { return dom.maybe(this._pageWidgetType, (type) => {
const widgetInfo = widgetTypes.get(type) || {label: 'Table', icon: 'TypeTable'}; const widgetInfo = widgetTypesMap.get(type) || {label: 'Table', icon: 'TypeTable'};
const fieldInfo = getFieldType(type); const fieldInfo = getFieldType(type);
return [ return [
cssTopBarItem(cssTopBarIcon(widgetInfo.icon), widgetInfo.label, cssTopBarItem(cssTopBarIcon(widgetInfo.icon), widgetInfo.label,
@ -359,7 +373,8 @@ export class RightPanel extends Disposable {
// refactored, but if not, should be made public. // refactored, but if not, should be made public.
const viewConfigTab = this._createViewConfigTab(owner); const viewConfigTab = this._createViewConfigTab(owner);
const hasCustomMapping = Computed.create(owner, use => { const hasCustomMapping = Computed.create(owner, use => {
const isCustom = use(this._pageWidgetType) === 'custom'; const widgetType = use(this._pageWidgetType);
const isCustom = widgetType === 'custom' || widgetType?.startsWith('custom.');
const hasColumnMapping = use(activeSection.columnsToMap); const hasColumnMapping = use(activeSection.columnsToMap);
return Boolean(isCustom && hasColumnMapping); return Boolean(isCustom && hasColumnMapping);
}); });
@ -441,10 +456,15 @@ export class RightPanel extends Disposable {
() => dom('div', parts[2].buildDom())), () => dom('div', parts[2].buildDom())),
// In the default url mode, allow picking a url and granting/forbidding // In the default url mode, allow picking a url and granting/forbidding
// access to data. // access to data.
dom.maybe(use => use(activeSection.customDef.mode) === 'url', dom.maybe(use => use(activeSection.customDef.mode) === 'url' && use(this._pageWidgetType) === 'custom',
() => dom.create(CustomSectionConfig, activeSection, this._gristDoc)), () => dom.create(CustomSectionConfig, activeSection, this._gristDoc)),
]; ];
}), }),
dom.maybe((use) => use(this._pageWidgetType)?.startsWith('custom.'), () => {
return [
dom.create(PredefinedCustomSectionConfig, activeSection, this._gristDoc),
];
}),
dom.maybe( dom.maybe(
(use) => !( (use) => !(

@ -5,7 +5,7 @@ import { makeT } from 'app/client/lib/localization';
import * as tableUtil from 'app/client/lib/tableUtil'; import * as tableUtil from 'app/client/lib/tableUtil';
import { ColumnRec, ViewFieldRec, ViewSectionRec } from "app/client/models/DocModel"; import { ColumnRec, ViewFieldRec, ViewSectionRec } from "app/client/models/DocModel";
import { getFieldType } from "app/client/ui/RightPanel"; import { getFieldType } from "app/client/ui/RightPanel";
import { IWidgetType } from "app/client/ui/widgetTypes"; import { IWidgetType } from "../../common/widgetTypes";
import { basicButton, cssButton, primaryButton } from 'app/client/ui2018/buttons'; import { basicButton, cssButton, primaryButton } from 'app/client/ui2018/buttons';
import * as checkbox from "app/client/ui2018/checkbox"; import * as checkbox from "app/client/ui2018/checkbox";
import { theme, vars } from "app/client/ui2018/cssVars"; import { theme, vars } from "app/client/ui2018/cssVars";

@ -1,28 +0,0 @@
/**
* Exposes utilities for getting the types information associated to each of the widget types.
*/
import { IconName } from "app/client/ui2018/IconList";
// all widget types
export type IWidgetType = 'record' | 'detail' | 'single' | 'chart' | 'custom';
// Widget type info.
export interface IWidgetTypeInfo {
label: string;
icon: IconName;
}
// the list of widget types with their labels and icons
export const widgetTypes = new Map<IWidgetType, IWidgetTypeInfo> ([
['record', {label: 'Table', icon: 'TypeTable'}],
['single', {label: 'Card', icon: 'TypeCard'}],
['detail', {label: 'Card List', icon: 'TypeCardList'}],
['chart', {label: 'Chart', icon: 'TypeChart'}],
['custom', {label: 'Custom', icon: 'TypeCustom'}]
]);
// Returns the widget type info for sectionType, or the one for 'record' if sectionType is null.
export function getWidgetTypes(sectionType: IWidgetType|null): IWidgetTypeInfo {
return widgetTypes.get(sectionType || 'record') || widgetTypes.get('record')!;
}

@ -0,0 +1,23 @@
// the list of widget types with their labels and icons
import {IWidgetType} from "app/common/widgetTypes";
import {IconName} from "app/client/ui2018/IconList";
export const widgetTypesMap = new Map<IWidgetType, IWidgetTypeInfo>([
['record', {label: 'Table', icon: 'TypeTable'}],
['single', {label: 'Card', icon: 'TypeCard'}],
['detail', {label: 'Card List', icon: 'TypeCardList'}],
['chart', {label: 'Chart', icon: 'TypeChart'}],
['custom', {label: 'Custom', icon: 'TypeCustom'}],
['custom.calendar', {label: 'Calendar', icon: 'FieldDate'}]
]);
// Widget type info.
export interface IWidgetTypeInfo {
label: string;
icon: IconName;
}
// Returns the widget type info for sectionType, or the one for 'record' if sectionType is null.
export function getWidgetTypes(sectionType: IWidgetType | null): IWidgetTypeInfo {
return widgetTypesMap.get(sectionType || 'record') || widgetTypesMap.get('record')!;
}

@ -8,6 +8,8 @@ import {TelemetryLevel} from 'app/common/Telemetry';
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
import {Document} from 'app/common/UserAPI'; import {Document} from 'app/common/UserAPI';
import {UIRowId} from 'app/plugin/GristAPI'; import {UIRowId} from 'app/plugin/GristAPI';
import {IAttachedCustomWidget} from "app/common/widgetTypes";
import {ThemeAppearance, ThemeAppearanceChecker, ThemeName, ThemeNameChecker} from './ThemePrefs';
import clone = require('lodash/clone'); import clone = require('lodash/clone');
import pickBy = require('lodash/pickBy'); import pickBy = require('lodash/pickBy');
import {ThemeAppearance, ThemeAppearanceChecker, ThemeName, ThemeNameChecker} from './ThemePrefs'; import {ThemeAppearance, ThemeAppearanceChecker, ThemeName, ThemeNameChecker} from './ThemePrefs';
@ -666,6 +668,8 @@ export interface GristLoadConfig {
// TODO: remove once released. // TODO: remove once released.
featureFormulaAssistant?: boolean; featureFormulaAssistant?: boolean;
permittedCustomWidgets?: IAttachedCustomWidget[];
// Used to determine which disclosure links should be provided to user of // Used to determine which disclosure links should be provided to user of
// formula assistance. // formula assistance.
assistantService?: 'OpenAI' | undefined; assistantService?: 'OpenAI' | undefined;

@ -0,0 +1,11 @@
/**
* Exposes utilities for getting the types information associated to each of the widget types.
*/
import {StringUnion} from "app/common/StringUnion";
// Custom widgets that are attached to "Add New" menu.
export const AttachedCustomWidgets = StringUnion('custom.calendar');
export type IAttachedCustomWidget = typeof AttachedCustomWidgets.type;
// all widget types
export type IWidgetType = 'record' | 'detail' | 'single' | 'chart' | 'custom' | IAttachedCustomWidget;

@ -2,6 +2,7 @@ import {Features, getPageTitleSuffix, GristLoadConfig, IFeature} from 'app/commo
import {isAffirmative} from 'app/common/gutil'; import {isAffirmative} from 'app/common/gutil';
import {getTagManagerSnippet} from 'app/common/tagManager'; import {getTagManagerSnippet} from 'app/common/tagManager';
import {Document} from 'app/common/UserAPI'; import {Document} from 'app/common/UserAPI';
import {AttachedCustomWidgets, IAttachedCustomWidget} from "app/common/widgetTypes";
import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager'; import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager';
import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer'; import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {RequestWithOrg} from 'app/server/lib/extractOrg';
@ -11,8 +12,8 @@ import {getSupportedEngineChoices} from 'app/server/lib/serverUtils';
import {readLoadedLngs, readLoadedNamespaces} from 'app/server/localization'; import {readLoadedLngs, readLoadedNamespaces} from 'app/server/localization';
import * as express from 'express'; import * as express from 'express';
import * as fse from 'fs-extra'; import * as fse from 'fs-extra';
import jsesc from 'jsesc';
import * as handlebars from 'handlebars'; import * as handlebars from 'handlebars';
import jsesc from 'jsesc';
import * as path from 'path'; import * as path from 'path';
import difference = require('lodash/difference'); import difference = require('lodash/difference');
@ -76,6 +77,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
featureComments: isAffirmative(process.env.COMMENTS), featureComments: isAffirmative(process.env.COMMENTS),
featureFormulaAssistant: Boolean(process.env.OPENAI_API_KEY || process.env.ASSISTANT_CHAT_COMPLETION_ENDPOINT), featureFormulaAssistant: Boolean(process.env.OPENAI_API_KEY || process.env.ASSISTANT_CHAT_COMPLETION_ENDPOINT),
assistantService: process.env.OPENAI_API_KEY ? 'OpenAI' : undefined, assistantService: process.env.OPENAI_API_KEY ? 'OpenAI' : undefined,
permittedCustomWidgets: getPermittedCustomWidgets(),
supportEmail: SUPPORT_EMAIL, supportEmail: SUPPORT_EMAIL,
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale, userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
telemetry: server?.getTelemetry().getTelemetryConfig(), telemetry: server?.getTelemetry().getTelemetryConfig(),
@ -166,6 +168,11 @@ function getFeatures(): IFeature[] {
return Features.checkAll(difference(enabledFeatures, disabledFeatures)); return Features.checkAll(difference(enabledFeatures, disabledFeatures));
} }
function getPermittedCustomWidgets(): IAttachedCustomWidget[] {
const widgetsList = process.env.PERMITTED_CUSTOM_WIDGETS?.split(',').map(widgetName=>`custom.${widgetName}`) ?? [];
return AttachedCustomWidgets.checkAll(widgetsList);
}
function configuredPageTitleSuffix() { function configuredPageTitleSuffix() {
const result = process.env.GRIST_PAGE_TITLE_SUFFIX; const result = process.env.GRIST_PAGE_TITLE_SUFFIX;
return result === "_blank" ? "" : result; return result === "_blank" ? "" : result;

@ -67,7 +67,7 @@ const appExists = (name) => runFetch(`flyctl status -a ${name}`).then(() => true
const appCreate = (name) => runAction(`flyctl launch --auto-confirm --name ${name} -r ewr -o ${org} --vm-memory 1024`); const appCreate = (name) => runAction(`flyctl launch --auto-confirm --name ${name} -r ewr -o ${org} --vm-memory 1024`);
const volCreate = (name, vol) => runAction(`flyctl volumes create ${vol} -s 1 -r ewr -y -a ${name}`); const volCreate = (name, vol) => runAction(`flyctl volumes create ${vol} -s 1 -r ewr -y -a ${name}`);
const volList = (name) => runFetch(`flyctl volumes list -a ${name} -j`).then(({stdout}) => JSON.parse(stdout)); const volList = (name) => runFetch(`flyctl volumes list -a ${name} -j`).then(({stdout}) => JSON.parse(stdout));
const appDeploy = (name, appRoot) => runAction(`flyctl deploy ${appRoot} --remote-only`, {shell: true, stdio: 'inherit'}); const appDeploy = (name, appRoot) => runAction(`flyctl deploy ${appRoot} --remote-only --region=ewr`, {shell: true, stdio: 'inherit'});
async function appDestroy(name) { async function appDestroy(name) {
await runAction(`flyctl apps destroy ${name} -y`); await runAction(`flyctl apps destroy ${name} -y`);

@ -9,6 +9,7 @@ processes = []
APP_HOME_URL="https://{APP_NAME}.fly.dev" APP_HOME_URL="https://{APP_NAME}.fly.dev"
APP_STATIC_URL="https://{APP_NAME}.fly.dev" APP_STATIC_URL="https://{APP_NAME}.fly.dev"
ALLOWED_WEBHOOK_DOMAINS="webhook.site" ALLOWED_WEBHOOK_DOMAINS="webhook.site"
PERMITTED_CUSTOM_WIDGETS="calendar"
GRIST_SINGLE_ORG="docs" GRIST_SINGLE_ORG="docs"
PORT = "8080" PORT = "8080"
FLY_DEPLOY_EXPIRATION = "{FLY_DEPLOY_EXPIRATION}" FLY_DEPLOY_EXPIRATION = "{FLY_DEPLOY_EXPIRATION}"

@ -0,0 +1,116 @@
import {ICustomWidget} from "app/common/CustomWidget";
import {getAppRoot} from "app/server/lib/places";
import {assert, By, driver} from "mocha-webdriver";
import path from "path";
import * as gu from "test/nbrowser/gristUtils";
import {server, setupTestSuite} from "test/nbrowser/testUtils";
import {serveSomething} from "test/server/customUtil";
import {EnvironmentSnapshot} from "test/server/testUtils";
describe('attachedCustomWidget NotepadWidget', function () {
this.timeout(20000);
const cleanup = setupTestSuite();
let oldEnv: EnvironmentSnapshot;
// Valid manifest url.
const manifestEndpoint = '/manifest.json';
// Valid widget url.
const widgetEndpoint = '/widget';
// Create some widgets:
const widget1: ICustomWidget = {widgetId: '1', name: 'Calendar', url: widgetEndpoint + '?name=Calendar'};
let widgetServerUrl = '';
// Holds widgets manifest content.
let widgets: ICustomWidget[] = [];
// Switches widget manifest url
function useManifest(url: string) {
return server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : '');
}
async function buildWidgetServer(){
// Create simple widget server that serves manifest.json file, some widgets and some error pages.
const widgetServer = await serveSomething(app => {
app.get(widgetEndpoint, (req, res) =>
res
.header('Content-Type', 'text/html')
.send('<html><head><script src="/grist-plugin-api.js"></script></head><body>\n' +
(req.query.name || req.query.access) + // send back widget name from query string or access level
'</body>'+
"<script>grist.ready({requiredAccess: 'full', columns: [{name: 'Content', type: 'Text'}],"+
" onEditOptions(){}})</script>"+
'</html>\n')
.end()
);
app.get(manifestEndpoint, (_, res) =>
res
.header('Content-Type', 'application/json')
// prefix widget endpoint with server address
.json(widgets.map(widget => ({...widget, url: `${widgetServerUrl}${widget.url}`})))
.end()
);
app.get('/grist-plugin-api.js', (_, res) =>
res.sendFile(
'grist-plugin-api.js', {
root: path.resolve(getAppRoot(), "static")
}));
});
cleanup.addAfterAll(widgetServer.shutdown);
widgetServerUrl = widgetServer.url;
widgets = [widget1];
}
before(async function () {
await buildWidgetServer();
oldEnv = new EnvironmentSnapshot();
process.env.PERMITTED_CUSTOM_WIDGETS = "calendar";
await server.restart();
await useManifest(manifestEndpoint);
const session = await gu.session().login();
await session.tempDoc(cleanup, 'Hello.grist');
});
after(async function () {
oldEnv.restore();
await server.restart();
});
it('should be able to attach Calendar Widget', async () => {
await gu.openAddWidgetToPage();
const notepadElement = await driver.findContent('.test-wselect-type', /Calendar/);
assert.exists(notepadElement, 'Calendar widget is not found in the list of widgets');
});
it('should not ask for permission', async () => {
await gu.addNewSection(/Calendar/, /Table1/, {selectBy: /TABLE1/});
await gu.getSection('TABLE1 Calendar').click();
await gu.toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-pagewidget').click();
await gu.waitForServer();
// Check if widget config panel is here
await driver.findWait('.test-config-container', 2000);
const widgetOptions = await driver.findWait('.test-config-widget-open-configuration', 2000);
const widgetMapping = await driver.find('.test-config-widget-mapping-for-Content');
const widgetSelection = await driver.findElements(By.css('.test-config-widget-select'));
const widgetPermission = await driver.findElements(By.css('.test-wselect-permission'));
assert.isEmpty(widgetSelection, 'Widget selection is not expected to be present');
assert.isEmpty(widgetPermission, 'Widget permission is not expected to be present');
assert.exists(widgetOptions, 'Widget options is expected to be present');
assert.exists(widgetMapping, 'Widget mapping is expected to be present');
});
it('should display the content of the widget', async () => {
await gu.getSection('TABLE1 Calendar').click();
try {
await driver.switchTo().frame(await driver.findWait('.custom_view', 1000));
const editor = await driver.findContentWait('body', "Calendar", 1000);
assert.exists(editor);
} finally {
await driver.switchTo().defaultContent();
}
});
});
Loading…
Cancel
Save