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