(core) custom widget appear as build-in widget

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

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

Reviewers: georgegevoian

Reviewed By: georgegevoian

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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';

View File

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

View File

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

View File

@ -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'
];

View 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.
}
}

View File

@ -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) => !(

View File

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

View File

@ -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')!;
}

View 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')!;
}

View File

@ -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
View 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;

View File

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

View File

@ -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`);

View File

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

View 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();
}
});
});