mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
b6a431dd58
commit
942fc96225
31
app/client/components/CustomCalendarView.ts
Normal file
31
app/client/components/CustomCalendarView.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
@ -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) {
|
|
||||||
|
|
||||||
|
|
||||||
|
constructor(protected _section: ViewSectionRec, private _gristDoc: GristDoc) {
|
||||||
super();
|
super();
|
||||||
|
this._customSectionConfigurationConfig = new CustomSectionConfigurationConfig(_section);
|
||||||
const api = _gristDoc.app.topAppModel.api;
|
|
||||||
|
|
||||||
// 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,6 @@ export class CustomSectionConfig extends Disposable {
|
|||||||
cssRow(select(this._currentAccess, levels), testId('access')),
|
cssRow(select(this._currentAccess, levels), testId('access')),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
dom.maybe(this._hasConfiguration, () =>
|
|
||||||
cssSection(
|
|
||||||
textButton(
|
|
||||||
t("Open configuration"),
|
|
||||||
dom.on('click', () => this._openConfiguration()),
|
|
||||||
testId('open-configuration')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
cssSection(
|
cssSection(
|
||||||
cssLink(
|
cssLink(
|
||||||
dom.attr('href', 'https://support.getgrist.com/widget-custom'),
|
dom.attr('href', 'https://support.getgrist.com/widget-custom'),
|
||||||
@ -468,38 +501,38 @@ 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) => {
|
cssSeparator(),
|
||||||
const createObs = (column: ColumnToMapImpl) => {
|
this._customSectionConfigurationConfig.buildDom(),
|
||||||
const obs = Computed.create(owner, use => {
|
cssSection(
|
||||||
const savedDefinition = use(this._section.customDef.columnsMapping) || {};
|
cssLink(
|
||||||
return savedDefinition[column.name];
|
dom.attr('href', 'https://support.getgrist.com/widget-custom'),
|
||||||
});
|
dom.attr('target', '_blank'),
|
||||||
obs.onWrite(async (value) => {
|
t("Learn more about custom widgets")
|
||||||
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'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
23
app/client/ui/PredefinedCustomSectionConfig.ts
Normal file
23
app/client/ui/PredefinedCustomSectionConfig.ts
Normal file
@ -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')!;
|
|
||||||
}
|
|
23
app/client/ui/widgetTypesMap.ts
Normal file
23
app/client/ui/widgetTypesMap.ts
Normal file
@ -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;
|
||||||
|
11
app/common/widgetTypes.ts
Normal file
11
app/common/widgetTypes.ts
Normal file
@ -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}"
|
||||||
|
116
test/nbrowser/CustomAttachedWidget.ts
Normal file
116
test/nbrowser/CustomAttachedWidget.ts
Normal file
@ -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…
Reference in New Issue
Block a user