diff --git a/app/client/components/CustomCalendarView.ts b/app/client/components/CustomCalendarView.ts new file mode 100644 index 00000000..7777edcd --- /dev/null +++ b/app/client/components/CustomCalendarView.ts @@ -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"; + } +} diff --git a/app/client/components/CustomView.ts b/app/client/components/CustomView.ts index 71c51d56..439ff4fb 100644 --- a/app/client/components/CustomView.ts +++ b/app/client/components/CustomView.ts @@ -1,10 +1,18 @@ import BaseView from 'app/client/components/BaseView'; -import {Cursor} from 'app/client/components/Cursor'; import * as commands from 'app/client/components/commands'; +import {Cursor} from 'app/client/components/Cursor'; import {GristDoc} from 'app/client/components/GristDoc'; -import {ConfigNotifier, CustomSectionAPIImpl, GristDocAPIImpl, GristViewImpl, - MinimumLevel, RecordNotifier, TableNotifier, WidgetAPIImpl, - WidgetFrame} from 'app/client/components/WidgetFrame'; +import { + ConfigNotifier, + CustomSectionAPIImpl, + GristDocAPIImpl, + GristViewImpl, + MinimumLevel, + RecordNotifier, + TableNotifier, + WidgetAPIImpl, + WidgetFrame +} from 'app/client/components/WidgetFrame'; import {CustomSectionElement, ViewProcess} from 'app/client/lib/CustomSectionElement'; import {Disposable} from 'app/client/lib/dispose'; import dom from 'app/client/lib/dom'; @@ -14,15 +22,16 @@ import {ViewSectionRec} from 'app/client/models/DocModel'; import {CustomViewSectionDef} from 'app/client/models/entities/ViewSectionRec'; import {UserError} from 'app/client/models/errors'; import {SortedRowSet} from 'app/client/models/rowset'; -import {PluginInstance} from 'app/common/PluginInstance'; -import {AccessLevel} from 'app/common/CustomWidget'; import {closeRegisteredMenu} from 'app/client/ui2018/menus'; +import {AccessLevel} from 'app/common/CustomWidget'; +import {PluginInstance} from 'app/common/PluginInstance'; import {getGristConfig} from 'app/common/urlUtils'; import {Events as BackboneEvents} from 'backbone'; import {dom as grains} from 'grainjs'; import * as ko from 'knockout'; import defaults = require('lodash/defaults'); + /** * CustomView components displays arbitrary html. There are two modes available, in the "url" mode * the content is hosted by a third-party (for instance a github page), as opposed to the "plugin" @@ -60,7 +69,7 @@ export class CustomView extends Disposable { protected gristDoc: GristDoc; protected cursor: Cursor; - private _customDef: CustomViewSectionDef; + protected customDef: CustomViewSectionDef; // state of the component private _foundPlugin: ko.Observable; @@ -70,14 +79,12 @@ export class CustomView extends Disposable { private _pluginInstance: PluginInstance|undefined; private _frame: WidgetFrame; // plugin frame (holding external page) - private _emptyWidgetPage: string; + public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { BaseView.call(this as any, gristDoc, viewSectionModel, { 'addNewRow': true }); - this._customDef = this.viewSection.customDef; - - this._emptyWidgetPage = new URL("custom-widget.html", getGristConfig().homeUrl!).href; + this.customDef = this.viewSection.customDef; this.autoDisposeCallback(() => { if (this._customSection) { @@ -89,27 +96,31 @@ export class CustomView extends Disposable { // Ensure that selecting another section in same plugin update the view. this._foundSection.extend({notify: 'always'}); - this.autoDispose(this._customDef.pluginId.subscribe(this._updatePluginInstance, this)); - this.autoDispose(this._customDef.sectionId.subscribe(this._updateCustomSection, this)); + this.autoDispose(this.customDef.pluginId.subscribe(this._updatePluginInstance, this)); + this.autoDispose(this.customDef.sectionId.subscribe(this._updateCustomSection, this)); this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.hasFocus)); this.viewPane = this.autoDispose(this._buildDom()); this._updatePluginInstance(); } + public async triggerPrint() { if (!this.isDisposed() && this._frame) { return await this._frame.callRemote('print'); } } + protected getEmptyWidgetPage(): string { + return new URL("custom-widget.html", getGristConfig().homeUrl!).href; + } /** * Find a plugin instance that matches the plugin id, update the `found` observables, then tries to * find a matching section. */ private _updatePluginInstance() { - const pluginId = this._customDef.pluginId(); + const pluginId = this.customDef.pluginId(); this._pluginInstance = this.gristDoc.docPluginManager.pluginsList.find(p => p.definition.id === pluginId); if (this._pluginInstance) { @@ -129,7 +140,7 @@ export class CustomView extends Disposable { if (!this._pluginInstance) { return; } - const sectionId = this._customDef.sectionId(); + const sectionId = this.customDef.sectionId(); this._customSection = CustomSectionElement.find(this._pluginInstance, sectionId); if (this._customSection) { @@ -142,8 +153,8 @@ export class CustomView extends Disposable { } private _buildDom() { - const {mode, url, access} = this._customDef; - const showPlugin = ko.pureComputed(() => this._customDef.mode() === "plugin"); + const {mode, url, access} = this.customDef; + const showPlugin = ko.pureComputed(() => this.customDef.mode() === "plugin"); // When both plugin and section are not found, let's show only plugin notification. const showPluginNotification = ko.pureComputed(() => showPlugin() && !this._foundPlugin()); @@ -161,12 +172,12 @@ export class CustomView extends Disposable { kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) => _mode === "url" ? this._buildIFrame(_url, (_access || AccessLevel.none) as AccessLevel) : null), kd.maybe(showPluginNotification, () => buildNotification('Plugin ', - dom('strong', kd.text(this._customDef.pluginId)), ' was not found', + dom('strong', kd.text(this.customDef.pluginId)), ' was not found', dom.testId('customView_notification_plugin') )), kd.maybe(showSectionNotification, () => buildNotification('Section ', - dom('strong', kd.text(this._customDef.sectionId)), ' was not found in plugin ', - dom('strong', kd.text(this._customDef.pluginId)), + dom('strong', kd.text(this.customDef.sectionId)), ' was not found in plugin ', + dom('strong', kd.text(this.customDef.pluginId)), dom.testId('customView_notification_section') )), // When showPluginContent() is true then _foundSection() is also and _customSection is not @@ -184,7 +195,7 @@ export class CustomView extends Disposable { private _buildIFrame(baseUrl: string, access: AccessLevel) { return grains.create(WidgetFrame, { - url: baseUrl || this._emptyWidgetPage, + url: baseUrl || this.getEmptyWidgetPage(), access, readonly: this.gristDoc.isReadonly.get(), configure: (frame) => { diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 610ef14f..d46ae3ce 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -47,7 +47,7 @@ import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {linkFromId, selectBy} from 'app/client/ui/selectBy'; import {WebhookPage} from 'app/client/ui/WebhookPage'; import {startWelcomeTour} from 'app/client/ui/WelcomeTour'; -import {IWidgetType} from 'app/client/ui/widgetTypes'; +import {IWidgetType} from 'app/common/widgetTypes'; import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer'; import {isNarrowScreen, mediaSmall, mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars'; import {IconName} from 'app/client/ui2018/IconList'; diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index e2c322e9..a7340c3e 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -1,12 +1,15 @@ import BaseView from 'app/client/components/BaseView'; +import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom'; import {ChartView} from 'app/client/components/ChartView'; import * as commands from 'app/client/components/commands'; +import {CustomCalendarView} from "app/client/components/CustomCalendarView"; import {CustomView} from 'app/client/components/CustomView'; import * as DetailView from 'app/client/components/DetailView'; import * as GridView from 'app/client/components/GridView'; import {GristDoc} from 'app/client/components/GristDoc'; import {BoxSpec, Layout} from 'app/client/components/Layout'; import {LayoutEditor} from 'app/client/components/LayoutEditor'; +import {LayoutTray} from 'app/client/components/LayoutTray'; import {printViewSection} from 'app/client/components/Printing'; import {Delay} from 'app/client/lib/Delay'; import {createObsArray} from 'app/client/lib/koArrayWrap'; @@ -15,14 +18,23 @@ import {reportError} from 'app/client/models/errors'; import {isNarrowScreen, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; -import {LayoutTray} from 'app/client/components/LayoutTray'; -import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom'; import {mod} from 'app/common/gutil'; +import { + Computed, + computedArray, + Disposable, + dom, + fromKo, + Holder, + IDomComponent, + MultiHolder, + Observable, + styled, + subscribe +} from 'grainjs'; import * as ko from 'knockout'; -import * as _ from 'underscore'; import debounce from 'lodash/debounce'; -import {Computed, computedArray, Disposable, dom, fromKo, Holder, - IDomComponent, MultiHolder, Observable, styled, subscribe} from 'grainjs'; +import * as _ from 'underscore'; // tslint:disable:no-console @@ -32,6 +44,7 @@ const viewSectionTypes: {[key: string]: any} = { chart: ChartView, single: DetailView, custom: CustomView, + 'custom.calendar': CustomCalendarView, }; function getInstanceConstructor(parentKey: string) { diff --git a/app/client/components/WidgetFrame.ts b/app/client/components/WidgetFrame.ts index 645590ee..f4b5b40f 100644 --- a/app/client/components/WidgetFrame.ts +++ b/app/client/components/WidgetFrame.ts @@ -89,7 +89,6 @@ export class WidgetFrame extends DisposableWithEvents { // Call custom configuration handler. _options.configure?.(this); } - /** * Attach an EventSource with desired access level. */ diff --git a/app/client/components/buildViewSectionDom.ts b/app/client/components/buildViewSectionDom.ts index fc57b8bd..0715638f 100644 --- a/app/client/components/buildViewSectionDom.ts +++ b/app/client/components/buildViewSectionDom.ts @@ -1,16 +1,16 @@ import BaseView from 'app/client/components/BaseView'; import {GristDoc} from 'app/client/components/GristDoc'; -import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; import {makeT} from 'app/client/lib/localization'; +import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; import {filterBar} from 'app/client/ui/FilterBar'; import {cssIcon} from 'app/client/ui/RightPanelStyles'; import {makeCollapsedLayoutMenu} from 'app/client/ui/ViewLayoutMenu'; import {cssDotsIconWrapper, cssMenu, viewSectionMenu} from 'app/client/ui/ViewSectionMenu'; import {buildWidgetTitle} from 'app/client/ui/WidgetTitle'; -import {getWidgetTypes} from 'app/client/ui/widgetTypes'; import {colors, isNarrowScreenObs, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu} from 'app/client/ui2018/menus'; +import {getWidgetTypes} from "app/client/ui/widgetTypesMap"; import {Computed, dom, DomElementArg, Observable, styled} from 'grainjs'; import {defaultMenuOptions} from 'popweasel'; diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index d43123bd..f02c0c6b 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -1,6 +1,7 @@ import BaseView from 'app/client/components/BaseView'; import {LinkingState} from 'app/client/components/LinkingState'; import {KoArray} from 'app/client/lib/koArray'; +import {ColumnToMapImpl} from 'app/client/models/ColumnToMap'; import { ColumnRec, DocModel, @@ -13,18 +14,17 @@ import { ViewFieldRec, ViewRec } from 'app/client/models/DocModel'; +import {BEHAVIOR} from 'app/client/models/entities/ColumnRec'; import * as modelUtil from 'app/client/models/modelUtil'; +import {removeRule, RuleOwner} from 'app/client/models/RuleOwner'; 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 {AccessLevel, ICustomWidget} from 'app/common/CustomWidget'; import {UserAction} from 'app/common/DocActions'; import {arrayRepeat} from 'app/common/gutil'; import {Sort} from 'app/common/SortSpec'; 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 {Computed, Holder, Observable} from 'grainjs'; import * as ko from 'knockout'; diff --git a/app/client/models/features.ts b/app/client/models/features.ts index d19a8d28..353fe0a3 100644 --- a/app/client/models/features.ts +++ b/app/client/models/features.ts @@ -1,6 +1,6 @@ import {getGristConfig} from 'app/common/urlUtils'; 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'; /** @@ -24,3 +24,12 @@ export function HAS_FORMULA_ASSISTANT() { export function WHICH_FORMULA_ASSISTANT() { return getGristConfig().assistantService; } + +export function PERMITTED_CUSTOM_WIDGETS(): Observable { + 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; +} diff --git a/app/client/ui/CustomSectionConfig.ts b/app/client/ui/CustomSectionConfig.ts index efa48232..4192c60d 100644 --- a/app/client/ui/CustomSectionConfig.ts +++ b/app/client/ui/CustomSectionConfig.ts @@ -1,6 +1,7 @@ import {allCommands} from 'app/client/components/commands'; import {GristDoc} from 'app/client/components/GristDoc'; import * as kf from 'app/client/lib/koForm'; +import {makeT} from 'app/client/lib/localization'; import {ColumnToMapImpl} from 'app/client/models/ColumnToMap'; import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; 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 {AccessLevel, ICustomWidget, isSatisfied} from 'app/common/CustomWidget'; import {GristLoadConfig} from 'app/common/gristUrls'; -import {nativeCompare, unwrap} from 'app/common/gutil'; -import {bundleChanges, Computed, Disposable, dom, fromKo, makeTestId, - MultiHolder, Observable, styled, UseCBOwner} from 'grainjs'; -import {makeT} from 'app/client/lib/localization'; +import {unwrap} from 'app/common/gutil'; +import { + bundleChanges, + Computed, + Disposable, + dom, + fromKo, + makeTestId, + MultiHolder, + Observable, + styled, + UseCBOwner +} from 'grainjs'; const t = makeT('CustomSectionConfig'); @@ -165,8 +175,7 @@ class ColumnListPicker extends Disposable { const columns = use(this._section.columns).filter(this._typeFilter(use)); const columnMap = new Map(columns.map(c => [c.id.peek(), c])); // Remove any columns that are no longer there. - const selectedFields = selectedRefs.map(s => columnMap.get(s)!).filter(c => Boolean(c)); - return selectedFields; + return selectedRefs.map(s => columnMap.get(s)!).filter(c => Boolean(c)); } private _renderItem(use: UseCBOwner, field: ColumnRec): any { return cssFieldEntry( @@ -221,27 +230,82 @@ class ColumnListPicker extends Disposable { } } +class CustomSectionConfigurationConfig extends Disposable{ + // Does widget has custom configuration. + private readonly _hasConfiguration: Computed; + 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 { + + protected _customSectionConfigurationConfig: CustomSectionConfigurationConfig; // Holds all available widget definitions. private _widgets: Observable; // Holds selected option (either custom string or a widgetId). - private _selectedId: Computed; + private readonly _selectedId: Computed; // Holds custom widget URL. - private _url: Computed; + private readonly _url: Computed; // 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 // user to approve or reject it. - private _desiredAccess: Observable; + private readonly _desiredAccess: Observable; // Current access level (stored inside a section). - private _currentAccess: Computed; - // Does widget has custom configuration. - private _hasConfiguration: Computed; + private readonly _currentAccess: Computed; - constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) { + + + + constructor(protected _section: ViewSectionRec, private _gristDoc: GristDoc) { super(); - - const api = _gristDoc.app.topAppModel.api; + this._customSectionConfigurationConfig = new CustomSectionConfigurationConfig(_section); // Test if we can offer widget list. const gristConfig: GristLoadConfig = (window as any).gristConfig || {}; @@ -249,29 +313,8 @@ export class CustomSectionConfig extends Disposable { // Array of available widgets - will be updated asynchronously. this._widgets = Observable.create(this, []); - - if (this._canSelect) { - // 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); - } + this._getWidgets().catch(reportError); + // Request for rest of the widgets. // Selected value from the dropdown (contains widgetId or "custom" string for Custom URL) this._selectedId = Computed.create(this, use => { @@ -350,8 +393,6 @@ export class CustomSectionConfig extends Disposable { // Clear intermediate state when section changes. this.autoDispose(_section.id.subscribe(() => this._reject())); - - this._hasConfiguration = Computed.create(this, use => use(_section.hasCustomOptions)); } public buildDom() { @@ -395,16 +436,17 @@ export class CustomSectionConfig extends Disposable { return dom( 'div', dom.autoDispose(holder), + this.shouldRenderWidgetSelector() && this._canSelect ? cssRow( - select(this._selectedId, options, { - defaultLabel: t("Select Custom Widget"), - menuCssClass: cssMenu.className, - }), - testId('select') - ) + select(this._selectedId, options, { + defaultLabel: t("Select Custom Widget"), + menuCssClass: cssMenu.className, + }), + testId('select') + ) : null, - dom.maybe(isCustom, () => [ + dom.maybe(isCustom && this.shouldRenderWidgetSelector(), () => [ cssRow( cssTextInput( this._url, @@ -452,15 +494,6 @@ export class CustomSectionConfig extends Disposable { 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( cssLink( dom.attr('href', 'https://support.getgrist.com/widget-custom'), @@ -468,38 +501,38 @@ export class CustomSectionConfig extends Disposable { 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)) - ]; - }) + cssSeparator(), + this._customSectionConfigurationConfig.buildDom(), + cssSection( + cssLink( + dom.attr('href', 'https://support.getgrist.com/widget-custom'), + dom.attr('target', '_blank'), + t("Learn more about custom widgets") + ) + ), ); } - private _openConfiguration(): void { - allCommands.openWidgetConfiguration.run(); + protected shouldRenderWidgetSelector(): boolean { + 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() { if (this._desiredAccess.get()) { this._currentAccess.set(this._desiredAccess.get()!); @@ -512,7 +545,6 @@ export class CustomSectionConfig extends Disposable { } } - const cssWarningWrapper = styled('div', ` padding-left: 8px; padding-top: 6px; diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts index 81ce82fe..7fb23733 100644 --- a/app/client/ui/PageWidgetPicker.ts +++ b/app/client/ui/PageWidgetPicker.ts @@ -1,23 +1,37 @@ -import { BehavioralPromptsManager } from 'app/client/components/BehavioralPromptsManager'; -import { GristDoc } from 'app/client/components/GristDoc'; -import { makeT } from 'app/client/lib/localization'; -import { reportError } from 'app/client/models/AppModel'; -import { ColumnRec, TableRec, ViewSectionRec } from 'app/client/models/DocModel'; -import { GristTooltips } from 'app/client/ui/GristTooltips'; -import { linkId, NoLink } from 'app/client/ui/selectBy'; -import { withInfoTooltip } from 'app/client/ui/tooltips'; -import { getWidgetTypes, IWidgetType } from 'app/client/ui/widgetTypes'; -import { bigPrimaryButton } from "app/client/ui2018/buttons"; -import { overflowTooltip } from "app/client/ui/tooltips"; -import { theme, vars } from "app/client/ui2018/cssVars"; -import { icon } from "app/client/ui2018/icons"; -import { spinnerModal } from 'app/client/ui2018/modals'; -import { isLongerThan, nativeCompare } from "app/common/gutil"; -import { computed, Computed, Disposable, dom, domComputed, DomElementArg, fromKo, IOption, select} from "grainjs"; -import { makeTestId, Observable, onKeyDown, styled} from "grainjs"; -import without = require('lodash/without'); +import {BehavioralPromptsManager} from 'app/client/components/BehavioralPromptsManager'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {makeT} from 'app/client/lib/localization'; +import {reportError} from 'app/client/models/AppModel'; +import {ColumnRec, TableRec, ViewSectionRec} from 'app/client/models/DocModel'; +import {PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features"; +import {GristTooltips} from 'app/client/ui/GristTooltips'; +import {linkId, NoLink} from 'app/client/ui/selectBy'; +import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips'; +import {getWidgetTypes} from "app/client/ui/widgetTypesMap"; +import {bigPrimaryButton} from "app/client/ui2018/buttons"; +import {theme, vars} from "app/client/ui2018/cssVars"; +import {icon} from "app/client/ui2018/icons"; +import {spinnerModal} from 'app/client/ui2018/modals'; +import {isLongerThan, nativeCompare} from "app/common/gutil"; +import {IAttachedCustomWidget, IWidgetType} from 'app/common/widgetTypes'; +import { + computed, + Computed, + Disposable, + dom, + domComputed, + DomElementArg, + fromKo, + IOption, + makeTestId, + Observable, + onKeyDown, + select, + styled +} from "grainjs"; 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'); @@ -79,7 +93,7 @@ const testId = makeTestId('test-wselect-'); // compatible types given the tableId and whether user is creating a new page or not. function getCompatibleTypes(tableId: TableId, isNewPage: boolean|undefined): IWidgetType[] { if (tableId !== 'New Table') { - return ['record', 'single', 'detail', 'chart', 'custom']; + return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar']; } else if (isNewPage) { // New view + new table means we'll be switching to the primary view. return ['record']; @@ -240,9 +254,15 @@ export interface ISelectOptions { selectBy?: (val: IPageWidget) => Array>; } +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. +const finalListOfCustomWidgetToShow = permittedCustomWidgets.filter(a=> + registeredCustomWidgets.includes(a)); const sectionTypes: IWidgetType[] = [ - 'record', 'single', 'detail', 'chart', 'custom' + 'record', 'single', 'detail', 'chart', ...finalListOfCustomWidgetToShow, 'custom' ]; diff --git a/app/client/ui/PredefinedCustomSectionConfig.ts b/app/client/ui/PredefinedCustomSectionConfig.ts new file mode 100644 index 00000000..b31bbdc4 --- /dev/null +++ b/app/client/ui/PredefinedCustomSectionConfig.ts @@ -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 { + // Do nothing. + } +} diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 2cbbc2f7..a9e21037 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -24,15 +24,16 @@ import {makeT} from 'app/client/lib/localization'; import {createSessionObs} from 'app/client/lib/sessionObs'; import {reportError} from 'app/client/models/AppModel'; 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 {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig'; 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 {linkId, selectBy} from 'app/client/ui/selectBy'; 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 {testId, theme, vars} from 'app/client/ui2018/cssVars'; 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 {FieldBuilder} from 'app/client/widgets/FieldBuilder'; import {StringUnion} from 'app/common/StringUnion'; -import {bundleChanges, Computed, Disposable, dom, domComputed, DomContents, - DomElementArg, DomElementMethod, IDomComponent} from 'grainjs'; -import {MultiHolder, Observable, styled, subscribe} from 'grainjs'; +import {IWidgetType} from 'app/common/widgetTypes'; +import { + bundleChanges, + Computed, + Disposable, + dom, + domComputed, + DomContents, + DomElementArg, + DomElementMethod, + IDomComponent, + MultiHolder, + Observable, + styled, + subscribe +} from 'grainjs'; import * as ko from 'knockout'; const t = makeT('RightPanel'); @@ -170,7 +184,7 @@ export class RightPanel extends Disposable { private _buildStandardHeader() { 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); return [ cssTopBarItem(cssTopBarIcon(widgetInfo.icon), widgetInfo.label, @@ -359,7 +373,8 @@ export class RightPanel extends Disposable { // refactored, but if not, should be made public. const viewConfigTab = this._createViewConfigTab(owner); 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); return Boolean(isCustom && hasColumnMapping); }); @@ -441,10 +456,15 @@ export class RightPanel extends Disposable { () => dom('div', parts[2].buildDom())), // In the default url mode, allow picking a url and granting/forbidding // 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.maybe((use) => use(this._pageWidgetType)?.startsWith('custom.'), () => { + return [ + dom.create(PredefinedCustomSectionConfig, activeSection, this._gristDoc), + ]; + }), dom.maybe( (use) => !( diff --git a/app/client/ui/VisibleFieldsConfig.ts b/app/client/ui/VisibleFieldsConfig.ts index ba4224b1..01cae124 100644 --- a/app/client/ui/VisibleFieldsConfig.ts +++ b/app/client/ui/VisibleFieldsConfig.ts @@ -5,7 +5,7 @@ import { makeT } from 'app/client/lib/localization'; import * as tableUtil from 'app/client/lib/tableUtil'; import { ColumnRec, ViewFieldRec, ViewSectionRec } from "app/client/models/DocModel"; 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 * as checkbox from "app/client/ui2018/checkbox"; import { theme, vars } from "app/client/ui2018/cssVars"; diff --git a/app/client/ui/widgetTypes.ts b/app/client/ui/widgetTypes.ts deleted file mode 100644 index b91a303d..00000000 --- a/app/client/ui/widgetTypes.ts +++ /dev/null @@ -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 ([ - ['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')!; -} diff --git a/app/client/ui/widgetTypesMap.ts b/app/client/ui/widgetTypesMap.ts new file mode 100644 index 00000000..9e2bc614 --- /dev/null +++ b/app/client/ui/widgetTypesMap.ts @@ -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([ + ['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')!; +} diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index d4f06b6d..92c5f641 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -8,6 +8,8 @@ import {TelemetryLevel} from 'app/common/Telemetry'; import {getGristConfig} from 'app/common/urlUtils'; import {Document} from 'app/common/UserAPI'; 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 pickBy = require('lodash/pickBy'); import {ThemeAppearance, ThemeAppearanceChecker, ThemeName, ThemeNameChecker} from './ThemePrefs'; @@ -666,6 +668,8 @@ export interface GristLoadConfig { // TODO: remove once released. featureFormulaAssistant?: boolean; + permittedCustomWidgets?: IAttachedCustomWidget[]; + // Used to determine which disclosure links should be provided to user of // formula assistance. assistantService?: 'OpenAI' | undefined; diff --git a/app/common/widgetTypes.ts b/app/common/widgetTypes.ts new file mode 100644 index 00000000..3b20ffbe --- /dev/null +++ b/app/common/widgetTypes.ts @@ -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; diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index ca5b31ff..d80bc0d3 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -2,6 +2,7 @@ import {Features, getPageTitleSuffix, GristLoadConfig, IFeature} from 'app/commo import {isAffirmative} from 'app/common/gutil'; import {getTagManagerSnippet} from 'app/common/tagManager'; import {Document} from 'app/common/UserAPI'; +import {AttachedCustomWidgets, IAttachedCustomWidget} from "app/common/widgetTypes"; import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager'; import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer'; 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 * as express from 'express'; import * as fse from 'fs-extra'; -import jsesc from 'jsesc'; import * as handlebars from 'handlebars'; +import jsesc from 'jsesc'; import * as path from 'path'; import difference = require('lodash/difference'); @@ -76,6 +77,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig featureComments: isAffirmative(process.env.COMMENTS), featureFormulaAssistant: Boolean(process.env.OPENAI_API_KEY || process.env.ASSISTANT_CHAT_COMPLETION_ENDPOINT), assistantService: process.env.OPENAI_API_KEY ? 'OpenAI' : undefined, + permittedCustomWidgets: getPermittedCustomWidgets(), supportEmail: SUPPORT_EMAIL, userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale, telemetry: server?.getTelemetry().getTelemetryConfig(), @@ -166,6 +168,11 @@ function getFeatures(): IFeature[] { 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() { const result = process.env.GRIST_PAGE_TITLE_SUFFIX; return result === "_blank" ? "" : result; diff --git a/buildtools/fly-deploy.js b/buildtools/fly-deploy.js index 24f2ab89..8df50687 100644 --- a/buildtools/fly-deploy.js +++ b/buildtools/fly-deploy.js @@ -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 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 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) { await runAction(`flyctl apps destroy ${name} -y`); diff --git a/buildtools/fly-template.toml b/buildtools/fly-template.toml index b6846e3f..2345f059 100644 --- a/buildtools/fly-template.toml +++ b/buildtools/fly-template.toml @@ -9,6 +9,7 @@ processes = [] APP_HOME_URL="https://{APP_NAME}.fly.dev" APP_STATIC_URL="https://{APP_NAME}.fly.dev" ALLOWED_WEBHOOK_DOMAINS="webhook.site" + PERMITTED_CUSTOM_WIDGETS="calendar" GRIST_SINGLE_ORG="docs" PORT = "8080" FLY_DEPLOY_EXPIRATION = "{FLY_DEPLOY_EXPIRATION}" diff --git a/test/nbrowser/CustomAttachedWidget.ts b/test/nbrowser/CustomAttachedWidget.ts new file mode 100644 index 00000000..98043770 --- /dev/null +++ b/test/nbrowser/CustomAttachedWidget.ts @@ -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('\n' + + (req.query.name || req.query.access) + // send back widget name from query string or access level + ''+ + ""+ + '\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(); + } + }); +});