(core) Add custom widget gallery

Summary:
Custom widgets are now shown in a gallery.

The gallery is automatically opened when a new custom widget is
added to a page.

Descriptions, authors, and update times are pulled from the widget
manifest.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D4309
pull/1150/head
George Gevoian 1 month ago
parent a16d76d25d
commit e70c294e3d

@ -42,6 +42,7 @@ import {getFilterFunc, QuerySetManager} from 'app/client/models/QuerySet';
import TableModel from 'app/client/models/TableModel'; import TableModel from 'app/client/models/TableModel';
import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs'; import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
import {App} from 'app/client/ui/App'; import {App} from 'app/client/ui/App';
import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
import {DocHistory} from 'app/client/ui/DocHistory'; import {DocHistory} from 'app/client/ui/DocHistory';
import {startDocTour} from "app/client/ui/DocTour"; import {startDocTour} from "app/client/ui/DocTour";
import {DocTutorial} from 'app/client/ui/DocTutorial'; import {DocTutorial} from 'app/client/ui/DocTutorial';
@ -138,6 +139,13 @@ interface PopupSectionOptions {
close: () => void; close: () => void;
} }
interface AddSectionOptions {
/** If focus should move to the new section. Defaults to `true`. */
focus?: boolean;
/** If popups should be shown (e.g. Card Layout tip). Defaults to `true`. */
popups?: boolean;
}
export class GristDoc extends DisposableWithEvents { export class GristDoc extends DisposableWithEvents {
public docModel: DocModel; public docModel: DocModel;
public viewModel: ViewRec; public viewModel: ViewRec;
@ -894,38 +902,27 @@ export class GristDoc extends DisposableWithEvents {
/** /**
* Adds a view section described by val to the current page. * Adds a view section described by val to the current page.
*/ */
public async addWidgetToPage(val: IPageWidget) { public async addWidgetToPage(widget: IPageWidget) {
const docData = this.docModel.docData; const {table, type} = widget;
const viewName = this.viewModel.name.peek();
let tableId: string | null | undefined; let tableId: string | null | undefined;
if (val.table === 'New Table') { if (table === 'New Table') {
tableId = await this._promptForName(); tableId = await this._promptForName();
if (tableId === undefined) { if (tableId === undefined) {
return; return;
} }
} }
if (type === 'custom') {
const widgetType = getTelemetryWidgetTypeFromPageWidget(val); return showCustomWidgetGallery(this, {
logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}}); addWidget: () => this._addWidgetToPage(widget, tableId),
if (val.link !== NoLink) { });
logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
} }
const res: {sectionRef: number} = await docData.bundleActions( const viewName = this.viewModel.name.peek();
const {sectionRef} = await this.docData.bundleActions(
t("Added new linked section to view {{viewName}}", {viewName}), t("Added new linked section to view {{viewName}}", {viewName}),
() => this.addWidgetToPageImpl(val, tableId ?? null) () => this._addWidgetToPage(widget, tableId ?? null)
); );
return sectionRef;
// The newly-added section should be given focus.
this.viewModel.activeSectionId(res.sectionRef);
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
if (AttachedCustomWidgets.guard(val.type)) {
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
}
return res.sectionRef;
} }
public async onCreateForm() { public async onCreateForm() {
@ -941,80 +938,31 @@ export class GristDoc extends DisposableWithEvents {
commands.allCommands.expandSection.run(); commands.allCommands.expandSection.run();
} }
/**
* The actual implementation of addWidgetToPage
*/
public async addWidgetToPageImpl(val: IPageWidget, tableId: string | null = null) {
const viewRef = this.activeViewId.get();
const tableRef = val.table === 'New Table' ? 0 : val.table;
const result = await this.docData.sendAction(
['CreateViewSection', tableRef, viewRef, val.type, val.summarize ? val.columns : null, tableId]
);
if (val.type === 'chart') {
await this._ensureOneNumericSeries(result.sectionRef);
}
if (val.type === 'form') {
await this._setDefaultFormLayoutSpec(result.sectionRef);
}
await this.saveLink(val.link, result.sectionRef);
return result;
}
/** /**
* Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`. * Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`.
*/ */
public async addNewPage(val: IPageWidget) { public async addNewPage(val: IPageWidget) {
logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}}); const {table, type} = val;
logTelemetryEvent('addedWidget', { let tableId: string | null | undefined;
full: { if (table === 'New Table') {
docIdDigest: this.docId(), tableId = await this._promptForName();
widgetType: getTelemetryWidgetTypeFromPageWidget(val), if (tableId === undefined) { return; }
},
});
let viewRef: IDocPage;
let sectionRef: number | undefined;
await this.docData.bundleActions('Add new page', async () => {
if (val.table === 'New Table') {
const name = await this._promptForName();
if (name === undefined) {
return;
}
if (val.type === WidgetType.Table) {
const result = await this.docData.sendAction(['AddEmptyTable', name]);
viewRef = result.views[0].id;
} else {
// This will create a new table and page.
const result = await this.docData.sendAction(
['CreateViewSection', /* new table */0, 0, val.type, null, name]
);
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
}
} else {
const result = await this.docData.sendAction(
['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null, null]
);
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
if (val.type === 'chart') {
await this._ensureOneNumericSeries(sectionRef!);
}
}
if (val.type === 'form') {
await this._setDefaultFormLayoutSpec(sectionRef!);
}
});
await this.openDocPage(viewRef!);
if (sectionRef) {
// The newly-added section should be given focus.
this.viewModel.activeSectionId(sectionRef);
} }
if (type === 'custom') {
this._maybeShowEditCardLayoutTip(val.type).catch(reportError); return showCustomWidgetGallery(this, {
addWidget: () => this._addPage(val, tableId ?? null) as Promise<{
if (AttachedCustomWidgets.guard(val.type)) { viewRef: number;
this._handleNewAttachedCustomWidget(val.type).catch(reportError); sectionRef: number;
}>,
});
} }
const {sectionRef, viewRef} = await this.docData.bundleActions(
'Add new page',
() => this._addPage(val, tableId ?? null)
);
await this._focus({sectionRef, viewRef});
this._showNewWidgetPopups(type);
} }
/** /**
@ -1460,6 +1408,90 @@ export class GristDoc extends DisposableWithEvents {
return values; return values;
} }
private async _addWidgetToPage(
widget: IPageWidget,
tableId: string | null = null,
{focus = true, popups = true}: AddSectionOptions= {}
) {
const {columns, link, summarize, table, type} = widget;
const viewRef = this.activeViewId.get();
const tableRef = table === 'New Table' ? 0 : table;
const result: {viewRef: number, sectionRef: number} = await this.docData.sendAction(
['CreateViewSection', tableRef, viewRef, type, summarize ? columns : null, tableId]
);
if (type === 'chart') {
await this._ensureOneNumericSeries(result.sectionRef);
}
if (type === 'form') {
await this._setDefaultFormLayoutSpec(result.sectionRef);
}
await this.saveLink(link, result.sectionRef);
const widgetType = getTelemetryWidgetTypeFromPageWidget(widget);
logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}});
if (link !== NoLink) {
logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
}
if (focus) { await this._focus({sectionRef: result.sectionRef}); }
if (popups) { this._showNewWidgetPopups(type); }
return result;
}
private async _addPage(
widget: IPageWidget,
tableId: string | null = null,
{focus = true, popups = true}: AddSectionOptions = {}
) {
const {columns, summarize, table, type} = widget;
let viewRef: number;
let sectionRef: number | undefined;
if (table === 'New Table') {
if (type === WidgetType.Table) {
const result = await this.docData.sendAction(['AddEmptyTable', tableId]);
viewRef = result.views[0].id;
} else {
// This will create a new table and page.
const result = await this.docData.sendAction(
['CreateViewSection', 0, 0, type, null, tableId]
);
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
}
} else {
const result = await this.docData.sendAction(
['CreateViewSection', table, 0, type, summarize ? columns : null, null]
);
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
if (type === 'chart') {
await this._ensureOneNumericSeries(sectionRef!);
}
}
if (type === 'form') {
await this._setDefaultFormLayoutSpec(sectionRef!);
}
logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}});
logTelemetryEvent('addedWidget', {
full: {
docIdDigest: this.docId(),
widgetType: getTelemetryWidgetTypeFromPageWidget(widget),
},
});
if (focus) { await this._focus({viewRef, sectionRef}); }
if (popups) { this._showNewWidgetPopups(type); }
return {viewRef, sectionRef};
}
private async _focus({viewRef, sectionRef}: {viewRef?: number, sectionRef?: number}) {
if (viewRef) { await this.openDocPage(viewRef); }
if (sectionRef) { this.viewModel.activeSectionId(sectionRef); }
}
private _showNewWidgetPopups(type: IWidgetType) {
this._maybeShowEditCardLayoutTip(type).catch(reportError);
if (AttachedCustomWidgets.guard(type)) {
this._handleNewAttachedCustomWidget(type).catch(reportError);
}
}
/** /**
* Opens popup with a section data (used by Raw Data view). * Opens popup with a section data (used by Raw Data view).
*/ */
@ -1718,7 +1750,7 @@ export class GristDoc extends DisposableWithEvents {
const sectionId = section.id(); const sectionId = section.id();
// create a new section // create a new section
const sectionCreationResult = await this.addWidgetToPageImpl(newVal); const sectionCreationResult = await this._addWidgetToPage(newVal, null, {focus: false, popups: false});
// update section name // update section name
const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef); const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef);

@ -223,10 +223,15 @@ export class WidgetFrame extends DisposableWithEvents {
// Appends access level to query string. // Appends access level to query string.
private _urlWithAccess(url: string) { private _urlWithAccess(url: string) {
if (!url) { if (!url) { return url; }
let urlObj: URL;
try {
urlObj = new URL(url);
} catch (e) {
console.error(e);
return url; return url;
} }
const urlObj = new URL(url);
urlObj.searchParams.append('access', this._options.access); urlObj.searchParams.append('access', this._options.access);
urlObj.searchParams.append('readonly', String(this._options.readonly)); urlObj.searchParams.append('readonly', String(this._options.readonly));
// Append user and document preferences to query string. // Append user and document preferences to query string.

@ -134,6 +134,10 @@ div:hover > .kf_tooltip {
z-index: 11; z-index: 11;
} }
.kf_prompt_content:focus {
outline: none;
}
.kf_draggable { .kf_draggable {
display: inline-block; display: inline-block;
} }

@ -62,8 +62,6 @@ export interface TopAppModel {
orgs: Observable<Organization[]>; orgs: Observable<Organization[]>;
users: Observable<FullUser[]>; users: Observable<FullUser[]>;
customWidgets: Observable<ICustomWidget[]|null>;
// Reinitialize the app. This is called when org or user changes. // Reinitialize the app. This is called when org or user changes.
initialize(): void; initialize(): void;
@ -162,26 +160,26 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
public readonly orgs = Observable.create<Organization[]>(this, []); public readonly orgs = Observable.create<Organization[]>(this, []);
public readonly users = Observable.create<FullUser[]>(this, []); public readonly users = Observable.create<FullUser[]>(this, []);
public readonly plugins: LocalPlugin[] = []; public readonly plugins: LocalPlugin[] = [];
public readonly customWidgets = Observable.create<ICustomWidget[]|null>(this, null); private readonly _gristConfig? = this._window.gristConfig;
private readonly _gristConfig?: GristLoadConfig;
// Keep a list of available widgets, once requested, so we don't have to // Keep a list of available widgets, once requested, so we don't have to
// keep reloading it. Downside: browser page will need reloading to pick // keep reloading it. Downside: browser page will need reloading to pick
// up new widgets - that seems ok. // up new widgets - that seems ok.
private readonly _widgets: AsyncCreate<ICustomWidget[]>; private readonly _widgets: AsyncCreate<ICustomWidget[]>;
constructor(window: {gristConfig?: GristLoadConfig}, constructor(private _window: {gristConfig?: GristLoadConfig},
public readonly api: UserAPI = newUserAPIImpl(), public readonly api: UserAPI = newUserAPIImpl(),
public readonly options: TopAppModelOptions = {} public readonly options: TopAppModelOptions = {}
) { ) {
super(); super();
setErrorNotifier(this.notifier); setErrorNotifier(this.notifier);
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg); this.isSingleOrg = Boolean(this._gristConfig?.singleOrg);
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org); this.productFlavor = getFlavor(this._gristConfig?.org);
this._gristConfig = window.gristConfig;
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => { this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
const widgets = this.options.useApi === false ? [] : await this.api.getWidgets(); if (this.options.useApi === false || !this._gristConfig?.enableWidgetRepository) {
this.customWidgets.set(widgets); return [];
return widgets; }
return await this.api.getWidgets();
}); });
// Initially, and on any change to subdomain, call initialize() to get the full Organization // Initially, and on any change to subdomain, call initialize() to get the full Organization
@ -214,8 +212,7 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
public async testReloadWidgets() { public async testReloadWidgets() {
console.log("testReloadWidgets"); console.log("testReloadWidgets");
this._widgets.clear(); this._widgets.clear();
this.customWidgets.set(null); console.log("testReloadWidgets cleared");
console.log("testReloadWidgets cleared and nulled");
const result = await this.getWidgets(); const result = await this.getWidgets();
console.log("testReloadWidgets got", {result}); console.log("testReloadWidgets got", {result});
} }

@ -1,11 +1,22 @@
import {allCommands} from 'app/client/components/commands'; import {allCommands} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {makeTestId} from 'app/client/lib/domUtils'; import {makeTestId} from 'app/client/lib/domUtils';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import * as kf from 'app/client/lib/koForm'; import * as kf from 'app/client/lib/koForm';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap'; import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors'; import {
cssDeveloperLink,
cssWidgetMetadata,
cssWidgetMetadataName,
cssWidgetMetadataRow,
cssWidgetMetadataValue,
CUSTOM_URL_WIDGET_ID,
getWidgetName,
showCustomWidgetGallery,
} from 'app/client/ui/CustomWidgetGallery';
import {cssHelp, cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles'; import {cssHelp, cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
import {hoverTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip} from 'app/client/ui/tooltips';
import {cssDragRow, cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig'; import {cssDragRow, cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig';
@ -14,16 +25,15 @@ import {theme, vars} from 'app/client/ui2018/cssVars';
import {cssDragger} from 'app/client/ui2018/draggableList'; import {cssDragger} from 'app/client/ui2018/draggableList';
import {textInput} from 'app/client/ui2018/editableLabel'; import {textInput} from 'app/client/ui2018/editableLabel';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {cssOptionLabel, IOption, IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus'; import {cssOptionLabel, IOption, IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget'; import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
import {GristLoadConfig} from 'app/common/gristUrls';
import {not, unwrap} from 'app/common/gutil'; import {not, unwrap} from 'app/common/gutil';
import { import {
bundleChanges, bundleChanges,
Computed, Computed,
Disposable, Disposable,
dom, dom,
DomContents,
fromKo, fromKo,
MultiHolder, MultiHolder,
Observable, Observable,
@ -33,22 +43,8 @@ import {
const t = makeT('CustomSectionConfig'); const t = makeT('CustomSectionConfig');
// Custom URL widget id - used as mock id for selectbox.
const CUSTOM_ID = 'custom';
const testId = makeTestId('test-config-widget-'); const testId = makeTestId('test-config-widget-');
/**
* Custom Widget section.
* Allows to select custom widget from the list of available widgets
* (taken from /widgets endpoint), or enter a Custom URL.
* When Custom Widget has a desired access level (in accessLevel field),
* will prompt user to approve it. "None" access level is auto approved,
* so prompt won't be shown.
*
* When gristConfig.enableWidgetRepository is set to false, it will only
* allow to specify the custom URL.
*/
class ColumnPicker extends Disposable { class ColumnPicker extends Disposable {
constructor( constructor(
private _value: Observable<number|number[]|null>, private _value: Observable<number|number[]|null>,
@ -319,17 +315,17 @@ class ColumnListPicker extends Disposable {
} }
class CustomSectionConfigurationConfig extends Disposable{ class CustomSectionConfigurationConfig extends Disposable{
// Does widget has custom configuration. private readonly _hasConfiguration = Computed.create(this, use =>
private readonly _hasConfiguration: Computed<boolean>; Boolean(use(this._section.hasCustomOptions) || use(this._section.columnsToMap)));
constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) { constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {
super(); super();
this._hasConfiguration = Computed.create(this, use => use(_section.hasCustomOptions));
} }
public buildDom() { public buildDom() {
// Show prompt, when desired access level is different from actual one. return dom.maybe(this._hasConfiguration, () => [
return dom( cssSeparator(),
'div', dom.maybe(this._section.hasCustomOptions, () =>
dom.maybe(this._hasConfiguration, () =>
cssSection( cssSection(
textButton( textButton(
t("Open configuration"), t("Open configuration"),
@ -363,7 +359,7 @@ class CustomSectionConfigurationConfig extends Disposable{
: dom.create(ColumnPicker, m.value, m.column, this._section)), : dom.create(ColumnPicker, m.value, m.column, this._section)),
); );
}) })
); ]);
} }
private _openConfiguration(): void { private _openConfiguration(): void {
allCommands.openWidgetConfiguration.run(); allCommands.openWidgetConfiguration.run();
@ -384,298 +380,304 @@ class CustomSectionConfigurationConfig extends Disposable{
} }
} }
/**
* Custom widget configuration.
*
* Allows picking a custom widget from a gallery of available widgets
* (fetched from the `/widgets` endpoint), which includes the Custom URL
* widget.
*
* When a custom widget has a desired `accessLevel` set to a value other
* than `"None"`, a prompt will be shown to grant the requested access level
* to the widget.
*
* When `gristConfig.enableWidgetRepository` is set to false, only the
* Custom URL widget will be available to select in the gallery.
*/
export class CustomSectionConfig extends Disposable { export class CustomSectionConfig extends Disposable {
protected _customSectionConfigurationConfig = new CustomSectionConfigurationConfig(
this._section, this._gristDoc);
private readonly _widgetId = Computed.create(this, use => {
// Stored in one of two places, depending on age of document.
const widgetId = use(this._section.customDef.widgetId) ||
use(this._section.customDef.widgetDef)?.widgetId;
if (widgetId) {
const pluginId = use(this._section.customDef.pluginId);
return (pluginId || '') + ':' + widgetId;
} else {
return CUSTOM_URL_WIDGET_ID;
}
});
protected _customSectionConfigurationConfig: CustomSectionConfigurationConfig; private readonly _isCustomUrlWidget = Computed.create(this, this._widgetId, (_use, widgetId) => {
// Holds all available widget definitions. return widgetId === CUSTOM_URL_WIDGET_ID;
private _widgets: Observable<ICustomWidget[]|null>; });
// Holds selected option (either custom string or a widgetId).
private readonly _selectedId: Computed<string | null>;
// Holds custom widget URL.
private readonly _url: Computed<string>;
// Enable or disable widget repository.
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 readonly _desiredAccess: Observable<AccessLevel|null>;
// Current access level (stored inside a section).
private readonly _currentAccess: Computed<AccessLevel>;
constructor(protected _section: ViewSectionRec, private _gristDoc: GristDoc) { private readonly _currentAccess = Computed.create(this, use =>
super(); (use(this._section.customDef.access) as AccessLevel) || AccessLevel.none)
this._customSectionConfigurationConfig = new CustomSectionConfigurationConfig(_section, _gristDoc); .onWrite(async newAccess => {
await this._section.customDef.access.setAndSave(newAccess);
// Test if we can offer widget list.
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
this._canSelect = gristConfig.enableWidgetRepository ?? true;
// Array of available widgets - will be updated asynchronously.
this._widgets = _gristDoc.app.topAppModel.customWidgets;
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 => {
// widgetId could be stored in one of two places, depending on
// age of document.
const widgetId = use(_section.customDef.widgetId) ||
use(_section.customDef.widgetDef)?.widgetId;
const pluginId = use(_section.customDef.pluginId);
if (widgetId) {
// selection id is "pluginId:widgetId"
return (pluginId || '') + ':' + widgetId;
}
return CUSTOM_ID;
});
this._selectedId.onWrite(async value => {
if (value === CUSTOM_ID) {
// Select Custom URL
bundleChanges(() => {
// Reset whether widget should render after `grist.ready()`.
_section.customDef.renderAfterReady(false);
// Clear url.
_section.customDef.url(null);
// Clear widgetId
_section.customDef.widgetId(null);
_section.customDef.widgetDef(null);
// Clear pluginId
_section.customDef.pluginId('');
// Reset access level to none.
_section.customDef.access(AccessLevel.none);
// Clear all saved options.
_section.customDef.widgetOptions(null);
// Reset custom configuration flag.
_section.hasCustomOptions(false);
// Clear column mappings.
_section.customDef.columnsMapping(null);
_section.columnsToMap(null);
this._desiredAccess.set(AccessLevel.none);
});
await _section.saveCustomDef();
} else {
const [pluginId, widgetId] = value?.split(':') || [];
// Select Widget
const selectedWidget = matchWidget(this._widgets.get()||[], {
widgetId,
pluginId,
});
if (!selectedWidget) {
// should not happen
throw new Error('Error accessing widget from the list');
}
// If user selected the same one, do nothing.
if (_section.customDef.widgetId.peek() === widgetId &&
_section.customDef.pluginId.peek() === pluginId) {
return;
}
bundleChanges(() => {
// Reset whether widget should render after `grist.ready()`.
_section.customDef.renderAfterReady(selectedWidget.renderAfterReady ?? false);
// Clear access level
_section.customDef.access(AccessLevel.none);
// When widget wants some access, set desired access level.
this._desiredAccess.set(selectedWidget.accessLevel || AccessLevel.none);
// Keep a record of the original widget definition.
// Don't rely on this much, since the document could
// have moved installation since, and widgets could be
// served from elsewhere.
_section.customDef.widgetDef(selectedWidget);
// Update widgetId.
_section.customDef.widgetId(selectedWidget.widgetId);
// Update pluginId.
_section.customDef.pluginId(selectedWidget.source?.pluginId || '');
// Update widget URL. Leave blank when widgetId is set.
_section.customDef.url(null);
// Clear options.
_section.customDef.widgetOptions(null);
// Clear has custom configuration.
_section.hasCustomOptions(false);
// Clear column mappings.
_section.customDef.columnsMapping(null);
_section.columnsToMap(null);
});
await _section.saveCustomDef();
}
}); });
// Url for the widget, taken either from widget definition, or provided by hand for Custom URL. private readonly _desiredAccess = fromKo(this._section.desiredAccessLevel);
// For custom widget, we will store url also in section definition.
this._url = Computed.create(this, use => use(_section.customDef.url) || ''); private readonly _url = Computed.create(this, use => use(this._section.customDef.url) || '')
this._url.onWrite(async newUrl => { .onWrite(async newUrl => {
bundleChanges(() => { bundleChanges(() => {
_section.customDef.renderAfterReady(false); this._section.customDef.renderAfterReady(false);
if (newUrl) { if (newUrl) {
// When a URL is set explicitly, make sure widgetId/pluginId/widgetDef this._section.customDef.widgetId(null);
// is empty. this._section.customDef.pluginId('');
_section.customDef.widgetId(null); this._section.customDef.widgetDef(null);
_section.customDef.pluginId('');
_section.customDef.widgetDef(null);
} }
_section.customDef.url(newUrl); this._section.customDef.url(newUrl);
}); });
await _section.saveCustomDef(); await this._section.saveCustomDef();
}); });
// Compute current access level. private readonly _requiresAccess = Computed.create(this, use => {
this._currentAccess = Computed.create( const [currentAccess, desiredAccess] = [use(this._currentAccess), use(this._desiredAccess)];
this, return desiredAccess && !isSatisfied(currentAccess, desiredAccess);
use => (use(_section.customDef.access) as AccessLevel) || AccessLevel.none });
);
this._currentAccess.onWrite(async newAccess => { private readonly _widgetDetailsExpanded: Observable<boolean>;
await _section.customDef.access.setAndSave(newAccess);
}); private readonly _widgets: Observable<ICustomWidget[] | null> = Observable.create(this, null);
// From the start desired access level is the same as current one.
this._desiredAccess = fromKo(_section.desiredAccessLevel); private readonly _selectedWidget = Computed.create(this, use => {
const id = use(this._widgetId);
if (id === CUSTOM_URL_WIDGET_ID) { return null; }
const widgets = use(this._widgets);
if (!widgets) { return null; }
const [pluginId, widgetId] = id.split(':');
return matchWidget(widgets, {pluginId, widgetId}) ?? null;
});
constructor(protected _section: ViewSectionRec, private _gristDoc: GristDoc) {
super();
const userId = this._gristDoc.appModel.currentUser?.id ?? 0;
this._widgetDetailsExpanded = this.autoDispose(localStorageBoolObs(
`u:${userId};customWidgetDetailsExpanded`,
true
));
this._getWidgets()
.then(widgets => {
if (this.isDisposed()) { return; }
this._widgets.set(widgets);
})
.catch(reportError);
// Clear intermediate state when section changes. // Clear intermediate state when section changes.
this.autoDispose(_section.id.subscribe(() => this._reject())); this.autoDispose(_section.id.subscribe(() => this._dismissAccessPrompt()));
} }
public buildDom() { public buildDom(): DomContents {
// UI observables holder. return dom('div',
const holder = new MultiHolder(); this._buildWidgetSelector(),
this._buildAccessLevelConfig(),
// Show prompt, when desired access level is different from actual one. this._customSectionConfigurationConfig.buildDom(),
const prompt = Computed.create(holder, use => );
use(this._desiredAccess) }
&& !isSatisfied(use(this._currentAccess), use(this._desiredAccess)!));
// If this is empty section or not. protected shouldRenderWidgetSelector(): boolean {
const isSelected = Computed.create(holder, use => Boolean(use(this._selectedId))); return true;
// If user is using custom url. }
const isCustom = Computed.create(holder, use => use(this._selectedId) === CUSTOM_ID || !this._canSelect);
// Options for the select-box (all widgets definitions and Custom URL) protected async _getWidgets() {
const options = Computed.create(holder, use => [ return await this._gristDoc.app.topAppModel.getWidgets();
{label: 'Custom URL', value: 'custom'}, }
...(use(this._widgets) || [])
.filter(w => w?.published !== false) private _buildWidgetSelector() {
.map(w => ({ if (!this.shouldRenderWidgetSelector()) { return null; }
label: w.source?.name ? `${w.name} (${w.source.name})` : w.name,
value: (w.source?.pluginId || '') + ':' + w.widgetId, return [
})), cssRow(
]); cssWidgetSelector(
function buildPrompt(level: AccessLevel|null) { this._buildShowWidgetDetailsButton(),
if (!level) { this._buildWidgetName(),
return null; ),
} ),
switch(level) { this._maybeBuildWidgetDetails(),
case AccessLevel.none: return cssConfirmLine(t("Widget does not require any permissions."));
case AccessLevel.read_table:
return cssConfirmLine(t("Widget needs to {{read}} the current table.", {read: dom("b", "read")}));
case AccessLevel.full:
return cssConfirmLine(t("Widget needs {{fullAccess}} to this document.", {
fullAccess: dom("b", "full access")
}));
default: throw new Error(`Unsupported ${level} access level`);
}
}
// Options for access level.
const levels: IOptionFull<string>[] = [
{label: t("No document access"), value: AccessLevel.none},
{label: t("Read selected table"), value: AccessLevel.read_table},
{label: t("Full document access"), value: AccessLevel.full},
]; ];
return dom( }
'div',
dom.autoDispose(holder), private _buildShowWidgetDetailsButton() {
this.shouldRenderWidgetSelector() && return cssShowWidgetDetails(
this._canSelect cssShowWidgetDetailsIcon(
? cssRow( 'Dropdown',
select(this._selectedId, options, { cssShowWidgetDetailsIcon.cls('-collapsed', use => !use(this._widgetDetailsExpanded)),
defaultLabel: t("Select Custom Widget"), testId('toggle-custom-widget-details'),
menuCssClass: cssMenu.className, testId(use => !use(this._widgetDetailsExpanded)
}), ? 'show-custom-widget-details'
testId('select') : 'hide-custom-widget-details'
) ),
: null, ),
dom.maybe((use) => use(isCustom) && this.shouldRenderWidgetSelector(), () => [ cssWidgetLabel(t('Widget')),
dom.on('click', () => {
this._widgetDetailsExpanded.set(!this._widgetDetailsExpanded.get());
}),
);
}
private _buildWidgetName() {
return cssWidgetName(
dom.text(use => {
if (use(this._isCustomUrlWidget)) {
return t('Custom URL');
} else {
const widget = use(this._selectedWidget) ?? use(this._section.customDef.widgetDef);
return widget ? getWidgetName(widget) : use(this._widgetId);
}
}),
dom.on('click', () => showCustomWidgetGallery(this._gristDoc, {
sectionRef: this._section.id(),
})),
testId('open-custom-widget-gallery'),
);
}
private _maybeBuildWidgetDetails() {
return dom.maybe(this._widgetDetailsExpanded, () =>
dom.domComputed(this._selectedWidget, (widget) =>
cssRow( cssRow(
this._buildWidgetDetails(widget),
)
)
);
}
private _buildWidgetDetails(widget: ICustomWidget | null) {
return dom.domComputed(this._isCustomUrlWidget, (isCustomUrlWidget) => {
if (isCustomUrlWidget) {
return cssCustomUrlDetails(
cssTextInput( cssTextInput(
this._url, this._url,
async value => this._url.set(value), async value => this._url.set(value),
dom.attr('placeholder', t("Enter Custom URL")), dom.show(this._isCustomUrlWidget),
testId('url') {placeholder: t('Enter Custom URL')},
), ),
this._gristDoc.behavioralPromptsManager.attachPopup('customURL', { );
popupOptions: { } else if (!widget?.description && !widget?.authors?.[0] && !widget?.lastUpdatedAt) {
placement: 'left-start', return cssDetailsMessage(t('Missing description and author information.'));
}, } else {
isDisabled: () => { return cssWidgetDetails(
// Disable tip if a custom widget is already selected. !widget?.description ? null : cssWidgetDescription(
return Boolean(this._selectedId.get() && !(isCustom.get() && this._url.get().trim() === '')); widget.description,
}, testId('custom-widget-description'),
}) ),
), cssWidgetMetadata(
]), !widget?.authors?.[0] ? null : cssWidgetMetadataRow(
dom.maybe(prompt, () => cssWidgetMetadataName(t('Developer:')),
kf.prompt( cssWidgetMetadataValue(
{tabindex: '-1'}, widget.authors[0].url
cssColumns( ? cssDeveloperLink(
cssWarningWrapper(icon('Lock')), widget.authors[0].name,
dom( {href: widget.authors[0].url, target: '_blank'},
'div', testId('custom-widget-developer'),
cssConfirmRow( )
dom.domComputed(this._desiredAccess, (level) => buildPrompt(level)) : dom('span',
widget.authors[0].name,
testId('custom-widget-developer'),
),
testId('custom-widget-developer'),
),
),
!widget?.lastUpdatedAt ? null : cssWidgetMetadataRow(
cssWidgetMetadataName(t('Last updated:')),
cssWidgetMetadataValue(
new Date(widget.lastUpdatedAt).toLocaleDateString('default', {
month: 'long',
day: 'numeric',
year: 'numeric',
}),
testId('custom-widget-last-updated'),
),
),
)
);
}
});
}
private _buildAccessLevelConfig() {
return [
cssSeparator({style: 'margin-top: 0px'}),
cssLabel(t('ACCESS LEVEL')),
cssRow(select(this._currentAccess, getAccessLevels()), testId('access')),
dom.maybeOwned(this._requiresAccess, (owner) => kf.prompt(
(elem: HTMLDivElement) => { FocusLayer.create(owner, {defaultFocusElem: elem, pauseMousetrap: true}); },
cssColumns(
cssWarningWrapper(icon('Lock')),
dom('div',
cssConfirmRow(
dom.domComputed(this._desiredAccess, (level) => this._buildAccessLevelPrompt(level))
),
cssConfirmRow(
primaryButton(
t('Accept'),
testId('access-accept'),
dom.on('click', () => this._grantDesiredAccess())
), ),
cssConfirmRow( basicButton(
primaryButton( t('Reject'),
'Accept', testId('access-reject'),
testId('access-accept'), dom.on('click', () => this._dismissAccessPrompt())
dom.on('click', () => this._accept())
),
basicButton(
'Reject',
testId('access-reject'),
dom.on('click', () => this._reject())
)
) )
) )
) )
) ),
), dom.onKeyDown({
dom.maybe( Enter: () => this._grantDesiredAccess(),
use => use(isSelected) || !this._canSelect, Escape:() => this._dismissAccessPrompt(),
() => [ }),
cssLabel('ACCESS LEVEL'), )),
cssRow(select(this._currentAccess, levels), testId('access')), ];
]
),
cssSection(
cssLink(
dom.attr('href', 'https://support.getgrist.com/widget-custom'),
dom.attr('target', '_blank'),
t("Learn more about custom widgets")
)
),
cssSeparator(),
this._customSectionConfigurationConfig.buildDom(),
);
} }
protected shouldRenderWidgetSelector(): boolean { private _buildAccessLevelPrompt(level: AccessLevel | null) {
return true; if (!level) { return null; }
}
protected async _getWidgets() { switch (level) {
await this._gristDoc.app.topAppModel.getWidgets(); case AccessLevel.none: {
return cssConfirmLine(t("Widget does not require any permissions."));
}
case AccessLevel.read_table: {
return cssConfirmLine(t("Widget needs to {{read}} the current table.", {read: dom("b", "read")}));
}
case AccessLevel.full: {
return cssConfirmLine(t("Widget needs {{fullAccess}} to this document.", {
fullAccess: dom("b", "full access")
}));
}
}
} }
private _accept() { private _grantDesiredAccess() {
if (this._desiredAccess.get()) { if (this._desiredAccess.get()) {
this._currentAccess.set(this._desiredAccess.get()!); this._currentAccess.set(this._desiredAccess.get()!);
} }
this._reject(); this._dismissAccessPrompt();
} }
private _reject() { private _dismissAccessPrompt() {
this._desiredAccess.set(null); this._desiredAccess.set(null);
} }
} }
function getAccessLevels(): IOptionFull<string>[] {
return [
{label: t("No document access"), value: AccessLevel.none},
{label: t("Read selected table"), value: AccessLevel.read_table},
{label: t("Full document access"), value: AccessLevel.full},
];
}
const cssWarningWrapper = styled('div', ` const cssWarningWrapper = styled('div', `
padding-left: 8px; padding-left: 8px;
padding-top: 6px; padding-top: 6px;
@ -700,12 +702,6 @@ const cssSection = styled('div', `
margin: 16px 16px 12px 16px; margin: 16px 16px 12px 16px;
`); `);
const cssMenu = styled('div', `
& > li:first-child {
border-bottom: 1px solid ${theme.menuBorder};
}
`);
const cssAddIcon = styled(icon, ` const cssAddIcon = styled(icon, `
margin-right: 4px; margin-right: 4px;
`); `);
@ -748,17 +744,9 @@ const cssAddMapping = styled('div', `
`); `);
const cssTextInput = styled(textInput, ` const cssTextInput = styled(textInput, `
flex: 1 0 auto;
color: ${theme.inputFg}; color: ${theme.inputFg};
background-color: ${theme.inputBg}; background-color: ${theme.inputBg};
&:disabled {
color: ${theme.inputDisabledFg};
background-color: ${theme.inputDisabledBg};
pointer-events: none;
}
&::placeholder { &::placeholder {
color: ${theme.inputPlaceholderFg}; color: ${theme.inputPlaceholderFg};
} }
@ -771,3 +759,62 @@ const cssDisabledSelect = styled(select, `
const cssBlank = styled(cssOptionLabel, ` const cssBlank = styled(cssOptionLabel, `
--grist-option-label-color: ${theme.lightText}; --grist-option-label-color: ${theme.lightText};
`); `);
const cssWidgetSelector = styled('div', `
width: 100%;
display: flex;
justify-content: space-between;
column-gap: 16px;
`);
const cssShowWidgetDetails = styled('div', `
display: flex;
align-items: center;
column-gap: 4px;
cursor: pointer;
`);
const cssShowWidgetDetailsIcon = styled(icon, `
--icon-color: ${theme.lightText};
flex-shrink: 0;
&-collapsed {
transform: rotate(-90deg);
}
`);
const cssWidgetLabel = styled('div', `
text-transform: uppercase;
font-size: ${vars.xsmallFontSize};
`);
const cssWidgetName = styled('div', `
color: ${theme.rightPanelCustomWidgetButtonFg};
background-color: ${theme.rightPanelCustomWidgetButtonBg};
height: 24px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`);
const cssWidgetDetails = styled('div', `
margin-top: 8px;
display: flex;
flex-direction: column;
margin-bottom: 8px;
`);
const cssCustomUrlDetails = styled(cssWidgetDetails, `
flex: 1 0 auto;
`);
const cssDetailsMessage = styled('div', `
color: ${theme.lightText};
`);
const cssWidgetDescription = styled('div', `
margin-bottom: 16px;
`);

@ -0,0 +1,661 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {textInput} from 'app/client/ui/inputs';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {withInfoTooltip} from 'app/client/ui/tooltips';
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {IModalControl, modal} from 'app/client/ui2018/modals';
import {AccessLevel, ICustomWidget, matchWidget, WidgetAuthor} from 'app/common/CustomWidget';
import {commonUrls} from 'app/common/gristUrls';
import {bundleChanges, Computed, Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
import escapeRegExp from 'lodash/escapeRegExp';
const testId = makeTestId('test-custom-widget-gallery-');
const t = makeT('CustomWidgetGallery');
export const CUSTOM_URL_WIDGET_ID = 'custom';
interface Options {
sectionRef?: number;
addWidget?(): Promise<{viewRef: number, sectionRef: number}>;
}
export function showCustomWidgetGallery(gristDoc: GristDoc, options: Options = {}) {
modal((ctl) => [
dom.create(CustomWidgetGallery, ctl, gristDoc, options),
cssModal.cls(''),
]);
}
interface WidgetInfo {
variant: WidgetVariant;
id: string;
name: string;
description?: string;
developer?: WidgetAuthor;
lastUpdated?: string;
}
interface CustomWidgetACItem extends ICustomWidget {
cleanText: string;
}
type WidgetVariant = 'custom' | 'grist' | 'community';
class CustomWidgetGallery extends Disposable {
private readonly _customUrl: Observable<string>;
private readonly _filteredWidgets = Observable.create<ICustomWidget[] | null>(this, null);
private readonly _section: ViewSectionRec | null = null;
private readonly _searchText = Observable.create(this, '');
private readonly _saveDisabled: Computed<boolean>;
private readonly _savedWidgetId: Computed<string | null>;
private readonly _selectedWidgetId = Observable.create<string | null>(this, null);
private readonly _widgets = Observable.create<CustomWidgetACItem[] | null>(this, null);
constructor(
private _ctl: IModalControl,
private _gristDoc: GristDoc,
private _options: Options = {}
) {
super();
const {sectionRef} = _options;
if (sectionRef) {
const section = this._gristDoc.docModel.viewSections.getRowModel(sectionRef);
if (!section.id.peek()) {
throw new Error(`Section ${sectionRef} does not exist`);
}
this._section = section;
this.autoDispose(section._isDeleted.subscribe((isDeleted) => {
if (isDeleted) { this._ctl.close(); }
}));
}
let customUrl = '';
if (this._section) {
customUrl = this._section.customDef.url() ?? '';
}
this._customUrl = Observable.create(this, customUrl);
this._savedWidgetId = Computed.create(this, (use) => {
if (!this._section) { return null; }
const {customDef} = this._section;
// May be stored in one of two places, depending on age of document.
const widgetId = use(customDef.widgetId) || use(customDef.widgetDef)?.widgetId;
if (widgetId) {
const pluginId = use(customDef.pluginId);
const widget = matchWidget(use(this._widgets) ?? [], {
widgetId,
pluginId,
});
return widget ? `${pluginId}:${widgetId}` : null;
} else {
return CUSTOM_URL_WIDGET_ID;
}
});
this._saveDisabled = Computed.create(this, use => {
const selectedWidgetId = use(this._selectedWidgetId);
if (!selectedWidgetId) { return true; }
if (!this._section) { return false; }
const savedWidgetId = use(this._savedWidgetId);
if (selectedWidgetId === CUSTOM_URL_WIDGET_ID) {
return (
use(this._savedWidgetId) === CUSTOM_URL_WIDGET_ID &&
use(this._customUrl) === use(this._section.customDef.url)
);
} else {
return selectedWidgetId === savedWidgetId;
}
});
this._initializeWidgets().catch(reportError);
this.autoDispose(this._searchText.addListener(() => {
this._filterWidgets();
this._selectedWidgetId.set(null);
}));
}
public buildDom() {
return cssCustomWidgetGallery(
cssHeader(
cssTitle(t('Choose Custom Widget')),
cssSearchInputWrapper(
cssSearchIcon('Search'),
cssSearchInput(
this._searchText,
{placeholder: t('Search')},
(el) => { setTimeout(() => el.focus(), 10); },
testId('search'),
),
),
),
shadowScroll(
this._buildWidgets(),
cssShadowScroll.cls(''),
),
cssFooter(
dom('div',
cssHelpLink(
{href: commonUrls.helpCustomWidgets, target: '_blank'},
cssHelpIcon('Question'),
t('Learn more about Custom Widgets'),
),
),
cssFooterButtons(
bigBasicButton(
t('Cancel'),
dom.on('click', () => this._ctl.close()),
testId('cancel'),
),
bigPrimaryButton(
this._options.addWidget ? t('Add Widget') : t('Change Widget'),
dom.on('click', () => this._save()),
dom.boolAttr('disabled', this._saveDisabled),
testId('save'),
),
),
),
dom.onKeyDown({
Enter: () => this._save(),
Escape: () => this._deselectOrClose(),
}),
dom.on('click', (ev) => this._maybeClearSelection(ev)),
testId('container'),
);
}
private async _initializeWidgets() {
const widgets: ICustomWidget[] = [
{
widgetId: 'custom',
name: t('Custom URL'),
description: t('Add a widget from outside this gallery.'),
url: '',
},
];
try {
const remoteWidgets = await this._gristDoc.appModel.topAppModel.getWidgets();
if (this.isDisposed()) { return; }
widgets.push(...remoteWidgets
.filter(({published}) => published !== false)
.sort((a, b) => a.name.localeCompare(b.name)));
} catch (e) {
reportError(e);
}
this._widgets.set(widgets.map(w => ({...w, cleanText: getWidgetCleanText(w)})));
this._selectedWidgetId.set(this._savedWidgetId.get());
this._filterWidgets();
}
private _filterWidgets() {
const widgets = this._widgets.get();
if (!widgets) { return; }
const searchText = this._searchText.get();
if (!searchText) {
this._filteredWidgets.set(widgets);
} else {
const searchTerms = searchText.trim().split(/\s+/);
const searchPatterns = searchTerms.map(term =>
new RegExp(`\\b${escapeRegExp(term)}`, 'i'));
const filteredWidgets = widgets.filter(({cleanText}) =>
searchPatterns.some(pattern => pattern.test(cleanText))
);
this._filteredWidgets.set(filteredWidgets);
}
}
private _buildWidgets() {
return dom.domComputed(this._filteredWidgets, (widgets) => {
if (widgets === null) {
return cssLoadingSpinner(loadingSpinner());
} else if (widgets.length === 0) {
return cssNoMatchingWidgets(t('No matching widgets'));
} else {
return cssWidgets(
widgets.map(widget => {
const {description, authors = [], lastUpdatedAt} = widget;
return this._buildWidget({
variant: getWidgetVariant(widget),
id: getWidgetId(widget),
name: getWidgetName(widget),
description,
developer: authors[0],
lastUpdated: lastUpdatedAt,
});
}),
);
}
});
}
private _buildWidget(info: WidgetInfo) {
const {variant, id, name, description, developer, lastUpdated} = info;
return cssWidget(
dom.cls('custom-widget'),
cssWidgetHeader(
variant === 'custom' ? t('Add Your Own Widget') :
variant === 'grist' ? t('Grist Widget') :
withInfoTooltip(
t('Community Widget'),
'communityWidgets',
{
variant: 'hover',
iconDomArgs: [cssTooltipIcon.cls('')],
}
),
cssWidgetHeader.cls('-secondary', ['custom', 'community'].includes(variant)),
),
cssWidgetBody(
cssWidgetName(
name,
testId('widget-name'),
),
cssWidgetDescription(
description ?? t('(Missing info)'),
cssWidgetDescription.cls('-missing', !description),
testId('widget-description'),
),
variant === 'custom' ? null : cssWidgetMetadata(
variant === 'grist' ? null : cssWidgetMetadataRow(
cssWidgetMetadataName(t('Developer:')),
cssWidgetMetadataValue(
developer?.url
? cssDeveloperLink(
developer.name,
{href: developer.url, target: '_blank'},
dom.on('click', (ev) => ev.stopPropagation()),
testId('widget-developer'),
)
: dom('span',
developer?.name ?? t('(Missing info)'),
testId('widget-developer'),
),
cssWidgetMetadataValue.cls('-missing', !developer?.name),
testId('widget-developer'),
),
),
cssWidgetMetadataRow(
cssWidgetMetadataName(t('Last updated:')),
cssWidgetMetadataValue(
lastUpdated ?
new Date(lastUpdated).toLocaleDateString('default', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
: t('(Missing info)'),
cssWidgetMetadataValue.cls('-missing', !lastUpdated),
testId('widget-last-updated'),
),
),
testId('widget-metadata'),
),
variant !== 'custom' ? null : cssCustomUrlInput(
this._customUrl,
{placeholder: t('Widget URL')},
testId('custom-url'),
),
),
cssWidget.cls('-selected', use => id === use(this._selectedWidgetId)),
dom.on('click', () => this._selectedWidgetId.set(id)),
testId('widget'),
testId(`widget-${variant}`),
);
}
private async _save() {
if (this._saveDisabled.get()) { return; }
await this._saveSelectedWidget();
this._ctl.close();
}
private async _deselectOrClose() {
if (this._selectedWidgetId.get()) {
this._selectedWidgetId.set(null);
} else {
this._ctl.close();
}
}
private async _saveSelectedWidget() {
await this._gristDoc.docData.bundleActions(
'Save selected custom widget',
async () => {
let section = this._section;
if (!section) {
const {addWidget} = this._options;
if (!addWidget) {
throw new Error('Cannot add custom widget: missing `addWidget` implementation');
}
const {sectionRef} = await addWidget();
const newSection = this._gristDoc.docModel.viewSections.getRowModel(sectionRef);
if (!newSection.id.peek()) {
throw new Error(`Section ${sectionRef} does not exist`);
}
section = newSection;
}
const selectedWidgetId = this._selectedWidgetId.get();
if (selectedWidgetId === CUSTOM_URL_WIDGET_ID) {
return this._saveCustomUrlWidget(section);
} else {
return this._saveRemoteWidget(section);
}
}
);
}
private async _saveCustomUrlWidget(section: ViewSectionRec) {
bundleChanges(() => {
section.customDef.renderAfterReady(false);
section.customDef.url(this._customUrl.get());
section.customDef.widgetId(null);
section.customDef.widgetDef(null);
section.customDef.pluginId('');
section.customDef.access(AccessLevel.none);
section.customDef.widgetOptions(null);
section.hasCustomOptions(false);
section.customDef.columnsMapping(null);
section.columnsToMap(null);
section.desiredAccessLevel(AccessLevel.none);
});
await section.saveCustomDef();
}
private async _saveRemoteWidget(section: ViewSectionRec) {
const [pluginId, widgetId] = this._selectedWidgetId.get()!.split(':');
const {customDef} = section;
if (customDef.pluginId.peek() === pluginId && customDef.widgetId.peek() === widgetId) {
return;
}
const selectedWidget = matchWidget(this._widgets.get() ?? [], {widgetId, pluginId});
if (!selectedWidget) {
throw new Error(`Widget ${this._selectedWidgetId.get()} not found`);
}
bundleChanges(() => {
section.customDef.renderAfterReady(selectedWidget.renderAfterReady ?? false);
section.customDef.access(AccessLevel.none);
section.desiredAccessLevel(selectedWidget.accessLevel ?? AccessLevel.none);
// Keep a record of the original widget definition.
// Don't rely on this much, since the document could
// have moved installation since, and widgets could be
// served from elsewhere.
section.customDef.widgetDef(selectedWidget);
section.customDef.widgetId(selectedWidget.widgetId);
section.customDef.pluginId(selectedWidget.source?.pluginId ?? '');
section.customDef.url(null);
section.customDef.widgetOptions(null);
section.hasCustomOptions(false);
section.customDef.columnsMapping(null);
section.columnsToMap(null);
});
await section.saveCustomDef();
}
private _maybeClearSelection(event: MouseEvent) {
const target = event.target as HTMLElement;
if (
!target.closest('.custom-widget') &&
!target.closest('button') &&
!target.closest('a') &&
!target.closest('input')
) {
this._selectedWidgetId.set(null);
}
}
}
export function getWidgetName({name, source}: ICustomWidget) {
return source?.name ? `${name} (${source.name})` : name;
}
function getWidgetVariant({isGristLabsMaintained = false, widgetId}: ICustomWidget): WidgetVariant {
if (widgetId === CUSTOM_URL_WIDGET_ID) {
return 'custom';
} else if (isGristLabsMaintained) {
return 'grist';
} else {
return 'community';
}
}
function getWidgetId({source, widgetId}: ICustomWidget) {
if (widgetId === CUSTOM_URL_WIDGET_ID) {
return CUSTOM_URL_WIDGET_ID;
} else {
return `${source?.pluginId ?? ''}:${widgetId}`;
}
}
function getWidgetCleanText({name, description, authors = []}: ICustomWidget) {
let cleanText = name;
if (description) { cleanText += ` ${description}`; }
if (authors[0]) { cleanText += ` ${authors[0].name}`; }
return cleanText;
}
export const cssWidgetMetadata = styled('div', `
margin-top: auto;
display: flex;
flex-direction: column;
row-gap: 4px;
`);
export const cssWidgetMetadataRow = styled('div', `
display: flex;
column-gap: 4px;
`);
export const cssWidgetMetadataName = styled('span', `
color: ${theme.lightText};
font-weight: 600;
`);
export const cssWidgetMetadataValue = styled('div', `
&-missing {
color: ${theme.lightText};
}
`);
export const cssDeveloperLink = styled(cssLink, `
font-weight: 600;
`);
const cssCustomWidgetGallery = styled('div', `
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
outline: none;
`);
const WIDGET_WIDTH_PX = 240;
const WIDGETS_GAP_PX = 16;
const cssHeader = styled('div', `
display: flex;
column-gap: 16px;
row-gap: 8px;
flex-wrap: wrap;
justify-content: space-between;
margin: 40px 40px 16px 40px;
/* Don't go beyond the final grid column. */
max-width: ${(3 * WIDGET_WIDTH_PX) + (2 * WIDGETS_GAP_PX)}px;
`);
const cssTitle = styled('div', `
font-size: 24px;
font-weight: 500;
line-height: 32px;
`);
const cssSearchInputWrapper = styled('div', `
position: relative;
display: flex;
align-items: center;
`);
const cssSearchIcon = styled(icon, `
margin-left: 8px;
position: absolute;
--icon-color: ${theme.accentIcon};
`);
const cssSearchInput = styled(textInput, `
height: 28px;
padding-left: 32px;
`);
const cssShadowScroll = styled('div', `
display: flex;
flex-direction: column;
flex: unset;
flex-grow: 1;
padding: 16px 40px;
`);
const cssCenteredFlexGrow = styled('div', `
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
`);
const cssLoadingSpinner = cssCenteredFlexGrow;
const cssNoMatchingWidgets = styled(cssCenteredFlexGrow, `
color: ${theme.lightText};
`);
const cssWidgets = styled('div', `
display: grid;
grid-template-columns: repeat(auto-fill, minmax(0px, ${WIDGET_WIDTH_PX}px));
gap: ${WIDGETS_GAP_PX}px;
`);
const cssWidget = styled('div', `
display: flex;
flex-direction: column;
box-shadow: 1px 1px 4px 1px ${theme.widgetGalleryShadow};
border-radius: 4px;
min-height: 183.5px;
cursor: pointer;
&:hover {
background-color: ${theme.widgetGalleryBgHover};
}
&-selected {
outline: 2px solid ${theme.widgetGalleryBorderSelected};
outline-offset: -2px;
}
`);
const cssWidgetHeader = styled('div', `
flex-shrink: 0;
border: 2px solid ${theme.widgetGalleryBorder};
border-bottom: 1px solid ${theme.widgetGalleryBorder};
border-radius: 4px 4px 0px 0px;
color: ${theme.lightText};
font-size: 10px;
line-height: 16px;
font-weight: 500;
padding: 4px 18px;
text-transform: uppercase;
&-secondary {
border: 0px;
color: ${theme.widgetGallerySecondaryHeaderFg};
background-color: ${theme.widgetGallerySecondaryHeaderBg};
}
.${cssWidget.className}:hover &-secondary {
background-color: ${theme.widgetGallerySecondaryHeaderBgHover};
}
`);
const cssWidgetBody = styled('div', `
display: flex;
flex-direction: column;
flex-grow: 1;
border: 2px solid ${theme.widgetGalleryBorder};
border-top: 0px;
border-radius: 0px 0px 4px 4px;
padding: 16px;
`);
const cssWidgetName = styled('div', `
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
`);
const cssWidgetDescription = styled('div', `
margin-bottom: 24px;
&-missing {
color: ${theme.lightText};
}
`);
const cssCustomUrlInput = styled(textInput, `
height: 28px;
`);
const cssHelpLink = styled(cssLink, `
display: inline-flex;
align-items: center;
column-gap: 8px;
`);
const cssHelpIcon = styled(icon, `
flex-shrink: 0;
`);
const cssFooter = styled('div', `
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 40px;
border-top: 1px solid ${theme.widgetGalleryBorder};
`);
const cssFooterButtons = styled('div', `
display: flex;
column-gap: 8px;
`);
const cssModal = styled('div', `
width: 100%;
height: 100%;
max-width: 930px;
max-height: 623px;
padding: 0px;
`);
const cssTooltipIcon = styled('div', `
color: ${theme.widgetGallerySecondaryHeaderFg};
border-color: ${theme.widgetGallerySecondaryHeaderFg};
`);

@ -42,7 +42,8 @@ export type Tooltip =
| 'formulaColumn' | 'formulaColumn'
| 'accessRulesTableWide' | 'accessRulesTableWide'
| 'setChoiceDropdownCondition' | 'setChoiceDropdownCondition'
| 'setRefDropdownCondition'; | 'setRefDropdownCondition'
| 'communityWidgets';
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents; export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
@ -152,6 +153,15 @@ see or edit which parts of your document.')
), ),
...args, ...args,
), ),
communityWidgets: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
t('Community widgets are created and maintained by Grist community members.')
),
dom('div',
cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.')),
),
...args,
),
}; };
export interface BehavioralPromptContent { export interface BehavioralPromptContent {
@ -307,20 +317,6 @@ to determine who can see or edit which parts of your document.')),
forceShow: true, forceShow: true,
markAsSeen: false, markAsSeen: false,
}, },
customURL: {
popupType: 'tip',
title: () => t('Custom Widgets'),
content: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
t(
'You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.'
),
),
dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))),
...args,
),
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
},
calendarConfig: { calendarConfig: {
popupType: 'tip', popupType: 'tip',
title: () => t('Calendar'), title: () => t('Calendar'),

@ -1,6 +1,7 @@
import {GristDoc} from "../components/GristDoc"; import {GristDoc} from 'app/client/components/GristDoc';
import {ViewSectionRec} from "../models/entities/ViewSectionRec"; import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
import {CustomSectionConfig} from "./CustomSectionConfig"; import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
import {ICustomWidget} from 'app/common/CustomWidget';
export class PredefinedCustomSectionConfig extends CustomSectionConfig { export class PredefinedCustomSectionConfig extends CustomSectionConfig {
@ -17,7 +18,7 @@ export class PredefinedCustomSectionConfig extends CustomSectionConfig {
return false; return false;
} }
protected async _getWidgets(): Promise<void> { protected async _getWidgets(): Promise<ICustomWidget[]> {
// Do nothing. return [];
} }
} }

@ -29,6 +29,7 @@ import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {reportError} from 'app/client/models/AppModel'; import {reportError} from 'app/client/models/AppModel';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig'; import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
import {BuildEditorOptions} from 'app/client/ui/FieldConfig'; import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
import {GridOptions} from 'app/client/ui/GridOptions'; import {GridOptions} from 'app/client/ui/GridOptions';
@ -526,7 +527,7 @@ export class RightPanel extends Disposable {
dom.maybe((use) => use(this._pageWidgetType) === 'custom', () => { dom.maybe((use) => use(this._pageWidgetType) === 'custom', () => {
const parts = vct._buildCustomTypeItems() as any[]; const parts = vct._buildCustomTypeItems() as any[];
return [ return [
cssLabel(t("CUSTOM")), cssSeparator(),
// If 'customViewPlugin' feature is on, show the toggle that allows switching to // If 'customViewPlugin' feature is on, show the toggle that allows switching to
// plugin mode. Note that the default mode for a new 'custom' view is 'url', so that's // plugin mode. Note that the default mode for a new 'custom' view is 'url', so that's
// the only one that will be shown without the feature flag. // the only one that will be shown without the feature flag.
@ -880,13 +881,20 @@ export class RightPanel extends Disposable {
private _createPageWidgetPicker(): DomElementMethod { private _createPageWidgetPicker(): DomElementMethod {
const gristDoc = this._gristDoc; const gristDoc = this._gristDoc;
const section = gristDoc.viewModel.activeSection; const {activeSection} = gristDoc.viewModel;
const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val); const onSave = async (val: IPageWidget) => {
return (elem) => { attachPageWidgetPicker(elem, gristDoc, onSave, { const {id} = await gristDoc.saveViewSection(activeSection.peek(), val);
buttonLabel: t("Save"), if (val.type === 'custom') {
value: () => toPageWidget(section.peek()), showCustomWidgetGallery(gristDoc, {sectionRef: id()});
selectBy: (val) => gristDoc.selectBy(val), }
}); }; };
return (elem) => {
attachPageWidgetPicker(elem, gristDoc, onSave, {
buttonLabel: t("Save"),
value: () => toPageWidget(activeSection.peek()),
selectBy: (val) => gristDoc.selectBy(val),
});
};
} }
// Returns dom for a section item. // Returns dom for a section item.

@ -38,7 +38,8 @@ function isAtScrollTop(elem: Element): boolean {
// Indicates that an element is currently scrolled such that the bottom of the element is visible. // Indicates that an element is currently scrolled such that the bottom of the element is visible.
// It is expected that the elem arg has the offsetHeight property set. // It is expected that the elem arg has the offsetHeight property set.
function isAtScrollBtm(elem: HTMLElement): boolean { function isAtScrollBtm(elem: HTMLElement): boolean {
return elem.scrollTop >= (elem.scrollHeight - elem.offsetHeight); // Check we're within a threshold of 1 pixel, to account for possible rounding.
return (elem.scrollHeight - elem.offsetHeight - elem.scrollTop) < 1;
} }
const cssScrollMenu = styled('div', ` const cssScrollMenu = styled('div', `

@ -119,6 +119,7 @@ export type IconName = "ChartArea" |
"Public" | "Public" |
"PublicColor" | "PublicColor" |
"PublicFilled" | "PublicFilled" |
"Question" |
"Redo" | "Redo" |
"Remove" | "Remove" |
"RemoveBig" | "RemoveBig" |
@ -280,6 +281,7 @@ export const IconList: IconName[] = ["ChartArea",
"Public", "Public",
"PublicColor", "PublicColor",
"PublicFilled", "PublicFilled",
"Question",
"Redo", "Redo",
"Remove", "Remove",
"RemoveBig", "RemoveBig",

@ -471,6 +471,10 @@ export const theme = {
undefined, colors.mediumGreyOpaque), undefined, colors.mediumGreyOpaque),
rightPanelFieldSettingsButtonBg: new CustomProp('theme-right-panel-field-settings-button-bg', rightPanelFieldSettingsButtonBg: new CustomProp('theme-right-panel-field-settings-button-bg',
undefined, 'lightgrey'), undefined, 'lightgrey'),
rightPanelCustomWidgetButtonFg: new CustomProp('theme-right-panel-custom-widget-button-fg',
undefined, colors.dark),
rightPanelCustomWidgetButtonBg: new CustomProp('theme-right-panel-custom-widget-button-bg',
undefined, colors.darkGrey),
/* Document History */ /* Document History */
documentHistorySnapshotFg: new CustomProp('theme-document-history-snapshot-fg', undefined, documentHistorySnapshotFg: new CustomProp('theme-document-history-snapshot-fg', undefined,
@ -877,6 +881,20 @@ export const theme = {
/* Numeric Spinners */ /* Numeric Spinners */
numericSpinnerFg: new CustomProp('theme-numeric-spinner-fg', undefined, '#606060'), numericSpinnerFg: new CustomProp('theme-numeric-spinner-fg', undefined, '#606060'),
/* Custom Widget Gallery */
widgetGalleryBorder: new CustomProp('theme-widget-gallery-border', undefined, colors.darkGrey),
widgetGalleryBorderSelected: new CustomProp('theme-widget-gallery-border-selected', undefined,
colors.lightGreen),
widgetGalleryShadow: new CustomProp('theme-widget-gallery-shadow', undefined, '#0000001A'),
widgetGalleryBgHover: new CustomProp('theme-widget-gallery-bg-hover', undefined,
colors.lightGrey),
widgetGallerySecondaryHeaderFg: new CustomProp('theme-widget-gallery-secondary-header-fg',
undefined, colors.light),
widgetGallerySecondaryHeaderBg: new CustomProp('theme-widget-gallery-secondary-header-bg',
undefined, colors.slate),
widgetGallerySecondaryHeaderBgHover: new CustomProp(
'theme-widget-gallery-secondary-header-bg-hover', undefined, '#7E7E85'),
}; };
const cssColors = values(colors).map(v => v.decl()).join('\n'); const cssColors = values(colors).map(v => v.decl()).join('\n');

@ -30,12 +30,10 @@ export interface ICustomWidget {
* applying the Grist theme. * applying the Grist theme.
*/ */
renderAfterReady?: boolean; renderAfterReady?: boolean;
/** /**
* If set to false, do not offer to user in UI. * If set to false, do not offer to user in UI.
*/ */
published?: boolean; published?: boolean;
/** /**
* If the widget came from a plugin, we track that here. * If the widget came from a plugin, we track that here.
*/ */
@ -43,6 +41,29 @@ export interface ICustomWidget {
pluginId: string; pluginId: string;
name: string; name: string;
}; };
/**
* Widget description.
*/
description?: string;
/**
* Widget authors.
*
* The first author is the one shown in the UI.
*/
authors?: WidgetAuthor[];
/**
* Date the widget was last updated.
*/
lastUpdatedAt?: string;
/**
* If the widget is maintained by Grist Labs.
*/
isGristLabsMaintained?: boolean;
}
export interface WidgetAuthor {
name: string;
url?: string;
} }
/** /**

@ -86,10 +86,10 @@ export const BehavioralPrompt = StringUnion(
'editCardLayout', 'editCardLayout',
'addNew', 'addNew',
'rickRow', 'rickRow',
'customURL',
'calendarConfig', 'calendarConfig',
// The following were used in the past and should not be re-used. // The following were used in the past and should not be re-used.
// 'customURL',
// 'formsAreHere', // 'formsAreHere',
); );
export type BehavioralPrompt = typeof BehavioralPrompt.type; export type BehavioralPrompt = typeof BehavioralPrompt.type;

@ -211,6 +211,8 @@ export const ThemeColors = t.iface([], {
"right-panel-toggle-button-disabled-bg": "string", "right-panel-toggle-button-disabled-bg": "string",
"right-panel-field-settings-bg": "string", "right-panel-field-settings-bg": "string",
"right-panel-field-settings-button-bg": "string", "right-panel-field-settings-button-bg": "string",
"right-panel-custom-widget-button-fg": "string",
"right-panel-custom-widget-button-bg": "string",
"document-history-snapshot-fg": "string", "document-history-snapshot-fg": "string",
"document-history-snapshot-selected-fg": "string", "document-history-snapshot-selected-fg": "string",
"document-history-snapshot-bg": "string", "document-history-snapshot-bg": "string",
@ -438,6 +440,13 @@ export const ThemeColors = t.iface([], {
"scroll-shadow": "string", "scroll-shadow": "string",
"toggle-checkbox-fg": "string", "toggle-checkbox-fg": "string",
"numeric-spinner-fg": "string", "numeric-spinner-fg": "string",
"widget-gallery-border": "string",
"widget-gallery-border-selected": "string",
"widget-gallery-shadow": "string",
"widget-gallery-bg-hover": "string",
"widget-gallery-secondary-header-fg": "string",
"widget-gallery-secondary-header-bg": "string",
"widget-gallery-secondary-header-bg-hover": "string",
}); });
const exportedTypeSuite: t.ITypeSuite = { const exportedTypeSuite: t.ITypeSuite = {

@ -269,6 +269,8 @@ export interface ThemeColors {
'right-panel-toggle-button-disabled-bg': string; 'right-panel-toggle-button-disabled-bg': string;
'right-panel-field-settings-bg': string; 'right-panel-field-settings-bg': string;
'right-panel-field-settings-button-bg': string; 'right-panel-field-settings-button-bg': string;
'right-panel-custom-widget-button-fg': string;
'right-panel-custom-widget-button-bg': string;
/* Document History */ /* Document History */
'document-history-snapshot-fg': string; 'document-history-snapshot-fg': string;
@ -572,6 +574,15 @@ export interface ThemeColors {
/* Numeric Spinners */ /* Numeric Spinners */
'numeric-spinner-fg': string; 'numeric-spinner-fg': string;
/* Custom Widget Gallery */
'widget-gallery-border': string;
'widget-gallery-border-selected': string;
'widget-gallery-shadow': string;
'widget-gallery-bg-hover': string;
'widget-gallery-secondary-header-fg': string;
'widget-gallery-secondary-header-bg': string;
'widget-gallery-secondary-header-bg-hover': string;
} }
export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>; export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>;

@ -759,7 +759,8 @@ export interface GristLoadConfig {
// List of registered plugins (used by HomePluginManager and DocPluginManager) // List of registered plugins (used by HomePluginManager and DocPluginManager)
plugins?: LocalPlugin[]; plugins?: LocalPlugin[];
// If custom widget list is available. // If additional custom widgets (besides the Custom URL widget) should be shown in
// the custom widget gallery.
enableWidgetRepository?: boolean; enableWidgetRepository?: boolean;
// Whether there is somewhere for survey data to go. // Whether there is somewhere for survey data to go.

@ -248,6 +248,8 @@ export const GristDark: ThemeColors = {
'right-panel-toggle-button-disabled-bg': '#32323F', 'right-panel-toggle-button-disabled-bg': '#32323F',
'right-panel-field-settings-bg': '#404150', 'right-panel-field-settings-bg': '#404150',
'right-panel-field-settings-button-bg': '#646473', 'right-panel-field-settings-button-bg': '#646473',
'right-panel-custom-widget-button-fg': '#EFEFEF',
'right-panel-custom-widget-button-bg': '#60606D',
/* Document History */ /* Document History */
'document-history-snapshot-fg': '#EFEFEF', 'document-history-snapshot-fg': '#EFEFEF',
@ -551,4 +553,13 @@ export const GristDark: ThemeColors = {
/* Numeric Spinners */ /* Numeric Spinners */
'numeric-spinner-fg': '#A4A4B1', 'numeric-spinner-fg': '#A4A4B1',
/* Custom Widget Gallery */
'widget-gallery-border': '#555563',
'widget-gallery-border-selected': '#17B378',
'widget-gallery-shadow': '#00000080',
'widget-gallery-bg-hover': '#262633',
'widget-gallery-secondary-header-fg': '#FFFFFF',
'widget-gallery-secondary-header-bg': '#70707D',
'widget-gallery-secondary-header-bg-hover': '#60606D',
}; };

@ -248,6 +248,8 @@ export const GristLight: ThemeColors = {
'right-panel-toggle-button-disabled-bg': '#E8E8E8', 'right-panel-toggle-button-disabled-bg': '#E8E8E8',
'right-panel-field-settings-bg': '#E8E8E8', 'right-panel-field-settings-bg': '#E8E8E8',
'right-panel-field-settings-button-bg': 'lightgrey', 'right-panel-field-settings-button-bg': 'lightgrey',
'right-panel-custom-widget-button-fg': '#262633',
'right-panel-custom-widget-button-bg': '#D9D9D9',
/* Document History */ /* Document History */
'document-history-snapshot-fg': '#262633', 'document-history-snapshot-fg': '#262633',
@ -551,4 +553,13 @@ export const GristLight: ThemeColors = {
/* Numeric Spinners */ /* Numeric Spinners */
'numeric-spinner-fg': '#606060', 'numeric-spinner-fg': '#606060',
/* Custom Widget Gallery */
'widget-gallery-border': '#D9D9D9',
'widget-gallery-border-selected': '#16B378',
'widget-gallery-shadow': '#0000001A',
'widget-gallery-bg-hover': '#F7F7F7',
'widget-gallery-secondary-header-fg': '#FFFFFF',
'widget-gallery-secondary-header-bg': '#929299',
'widget-gallery-secondary-header-bg-hover': '#7E7E85',
}; };

@ -120,6 +120,7 @@
--icon-Public: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6Ii8+PGcgc3Ryb2tlPSIjMTZCMzc4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik05Ljc3NSAxMS4yOTNMNy4zMTIgMTAuNTkzQzcuMTIyMjE2NjEgMTAuNTM4OTUxMSA2Ljk4MTk3MzAyIDEwLjM3ODMzNyA2Ljk1NCAxMC4xODNMNi43NDcgOC43MjZDNy44MTQxMjAyNiA4LjIzODMwNjE1IDguNDk5MDAyMDkgNy4xNzMyODE3IDguNSA2TDguNSA0LjYyNkM4LjUyNTE5NzU2IDIuOTczMDAzMjUgNy4yNDI1MDU0NyAxLjU5NDE1MzM3IDUuNTkyIDEuNSA0Ljc4MDUzMjcgMS40NzUxMDMyIDMuOTkzNjIwMiAxLjc4MDE0ODI3IDMuNDEwOTUzMjMgMi4zNDU0Nzg0NiAyLjgyODI4NjI1IDIuOTEwODA4NjQgMi40OTk2MTgxNiAzLjY4ODE1MDk1IDIuNSA0LjVMMi41IDZDMi41MDA5OTc5MSA3LjE3MzI4MTcgMy4xODU4Nzk3NCA4LjIzODMwNjE1IDQuMjUzIDguNzI2TDQuMDQ2IDEwLjE3OUM0LjAxODAyNjk4IDEwLjM3NDMzNyAzLjg3Nzc4MzM5IDEwLjUzNDk1MTEgMy42ODggMTAuNTg5TDEuMjI1IDExLjI4OUMuNzk1OTk1Njg4IDExLjQxMTcwNzIuNTAwMTk4MjMgMTEuODAzNzkxOC41IDEyLjI1TC41IDE0LjUgMTAuNSAxNC41IDEwLjUgMTIuMjU0QzEwLjQ5OTgwMTggMTEuODA3NzkxOCAxMC4yMDQwMDQzIDExLjQxNTcwNzIgOS43NzUgMTEuMjkzek0xMi41IDE0LjVMMTUuNSAxNC41IDE1LjUgMTEuMjgxQzE1LjQ5OTk4NzkgMTAuODIyMzIwNiAxNS4xODc5MzExIDEwLjQyMjQ1OTEgMTQuNzQzIDEwLjMxMUwxMS44MjYgOS41ODJDMTEuNjI4NDcxIDkuNTMyNzA4NDEgMTEuNDgwNTUyNCA5LjM2ODU3NDEgMTEuNDUyIDkuMTY3TDExLjI0NyA3LjcyNkMxMi4zMTQxMjAzIDcuMjM4MzA2MTUgMTIuOTk5MDAyMSA2LjE3MzI4MTcgMTMgNUwxMyAzLjYyNkMxMy4wMjUxOTc2IDEuOTczMDAzMjUgMTEuNzQyNTA1NS41OTQxNTMzNjUgMTAuMDkyLjUgOS41MzQ0NjYxNC40ODI3MzcyNzIgOC45ODMxNjAzOS42MjEyNTYzMDYgOC41LjkiLz48L2c+PC9nPjwvc3ZnPg=='); --icon-Public: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6Ii8+PGcgc3Ryb2tlPSIjMTZCMzc4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik05Ljc3NSAxMS4yOTNMNy4zMTIgMTAuNTkzQzcuMTIyMjE2NjEgMTAuNTM4OTUxMSA2Ljk4MTk3MzAyIDEwLjM3ODMzNyA2Ljk1NCAxMC4xODNMNi43NDcgOC43MjZDNy44MTQxMjAyNiA4LjIzODMwNjE1IDguNDk5MDAyMDkgNy4xNzMyODE3IDguNSA2TDguNSA0LjYyNkM4LjUyNTE5NzU2IDIuOTczMDAzMjUgNy4yNDI1MDU0NyAxLjU5NDE1MzM3IDUuNTkyIDEuNSA0Ljc4MDUzMjcgMS40NzUxMDMyIDMuOTkzNjIwMiAxLjc4MDE0ODI3IDMuNDEwOTUzMjMgMi4zNDU0Nzg0NiAyLjgyODI4NjI1IDIuOTEwODA4NjQgMi40OTk2MTgxNiAzLjY4ODE1MDk1IDIuNSA0LjVMMi41IDZDMi41MDA5OTc5MSA3LjE3MzI4MTcgMy4xODU4Nzk3NCA4LjIzODMwNjE1IDQuMjUzIDguNzI2TDQuMDQ2IDEwLjE3OUM0LjAxODAyNjk4IDEwLjM3NDMzNyAzLjg3Nzc4MzM5IDEwLjUzNDk1MTEgMy42ODggMTAuNTg5TDEuMjI1IDExLjI4OUMuNzk1OTk1Njg4IDExLjQxMTcwNzIuNTAwMTk4MjMgMTEuODAzNzkxOC41IDEyLjI1TC41IDE0LjUgMTAuNSAxNC41IDEwLjUgMTIuMjU0QzEwLjQ5OTgwMTggMTEuODA3NzkxOCAxMC4yMDQwMDQzIDExLjQxNTcwNzIgOS43NzUgMTEuMjkzek0xMi41IDE0LjVMMTUuNSAxNC41IDE1LjUgMTEuMjgxQzE1LjQ5OTk4NzkgMTAuODIyMzIwNiAxNS4xODc5MzExIDEwLjQyMjQ1OTEgMTQuNzQzIDEwLjMxMUwxMS44MjYgOS41ODJDMTEuNjI4NDcxIDkuNTMyNzA4NDEgMTEuNDgwNTUyNCA5LjM2ODU3NDEgMTEuNDUyIDkuMTY3TDExLjI0NyA3LjcyNkMxMi4zMTQxMjAzIDcuMjM4MzA2MTUgMTIuOTk5MDAyMSA2LjE3MzI4MTcgMTMgNUwxMyAzLjYyNkMxMy4wMjUxOTc2IDEuOTczMDAzMjUgMTEuNzQyNTA1NS41OTQxNTMzNjUgMTAuMDkyLjUgOS41MzQ0NjYxNC40ODI3MzcyNzIgOC45ODMxNjAzOS42MjEyNTYzMDYgOC41LjkiLz48L2c+PC9nPjwvc3ZnPg==');
--icon-PublicColor: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTQiIHdpZHRoPSIxNiI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIG9wYWNpdHk9Ii43ODMiPjxwYXRoIGQ9Ik0gMTUuMjc1LDEwLjI5MyAxMy4wODcsOS42NjggQyAxMi43MjY0NzgsOS41NjQ5MTc4IDEyLjQ1MzkyLDkuMjY4ODEwNSAxMi4zODEsOC45MDEgTCAxMi4yNDcsOC4yMjYgQyAxMy4zMTQxMiw3LjczODMwNjEgMTMuOTk5MDAyLDYuNjczMjgxNyAxNCw1LjUgViA0LjEyNiBDIDE0LjAyNTE5OCwyLjQ3MzAwMzMgMTIuNzQyNTA2LDEuMDk0MTUzNCAxMS4wOTIsMSA5Ljg3OTM3MDksMC45NjM0MTA4MiA4Ljc2NDA2NTUsMS42NjA3NzI3IDguMjY2LDIuNzY3IDguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4LDQuMjgzNjI0NyA5LDUuMTI2IFYgNi41IEMgOC45OTgyMDc3LDYuODYzNzcwNiA4Ljk0NjA0NTYsNy4yMjU1NDA0IDguODQ1LDcuNTc1IDkuMTA0MjMyNSw3Ljg0Njk0MjMgOS40MTIyMzk1LDguMDY3NzcxMSA5Ljc1Myw4LjIyNiBMIDkuNjE5LDguOSBDIDkuNTQ2MDc5Niw5LjI2NzgxMDUgOS4yNzM1MjE5LDkuNTYzOTE3OCA4LjkxMyw5LjY2NyBMIDguMDcsOS45MDggOS41NSwxMC4zMzEgYyAwLjg1Njk2NCwwLjI0NzU1NCAxLjQ0NzcwNiwxLjAzMDk5OSAxLjQ1LDEuOTIzIFYgMTQuNSBjIC0wLjAwMTcsMC4xNzA3MiAtMC4wMzI3OCwwLjMzOTg3MSAtMC4wOTIsMC41IEggMTUuNSBjIDAuMjc2MTQyLDAgMC41LC0wLjIyMzg1OCAwLjUsLTAuNSB2IC0zLjI0NiBjIC0xLjk4ZS00LC0wLjQ0NjIwOCAtMC4yOTU5OTYsLTAuODM4MjkzIC0wLjcyNSwtMC45NjEgeiIgZmlsbD0iI2U2YTExNyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNIDkuMjc1LDExLjI5MyA3LjA4NywxMC42NjggQyA2LjcyNjIwMjMsMTAuNTY0NzU0IDYuNDUzNTgzLDEwLjI2ODE5NCA2LjM4MSw5LjkgTCA2LjI0Nyw5LjIyNSBDIDcuMzEzNzkxNCw4LjczNzQ1NjkgNy45OTg2MTEyLDcuNjcyOTE5NSA4LDYuNSBWIDUuMTI2IEMgOC4wMjUxOTc2LDMuNDczMDAzMyA2Ljc0MjUwNTUsMi4wOTQxNTM0IDUuMDkyLDIgNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgzIDIuOTEwOTUzMiwyLjg0NTQ3ODUgMi4zMjgyODYzLDMuNDEwODA4NiAxLjk5OTYxODIsNC4xODgxNTA5IDIsNSB2IDEuNSBjIDkuOTc5ZS00LDEuMTczMjgxNyAwLjY4NTg3OTcsMi4yMzgzMDYyIDEuNzUzLDIuNzI2IEwgMy42MTksOS45IEMgMy41NDYwOCwxMC4yNjc4MTEgMy4yNzM1MjE5LDEwLjU2MzkxOCAyLjkxMywxMC42NjcgTCAwLjcyNSwxMS4yOTIgQyAwLjI5NTYzOTI2LDExLjQxNDgwOSAtMi40ODE3NzE2ZS00LDExLjgwNzQyMSAwLDEyLjI1NCBWIDE0LjUgQyAwLDE0Ljc3NjE0MiAwLjIyMzg1NzYzLDE1IDAuNSwxNSBoIDkgQyA5Ljc3NjE0MjQsMTUgMTAsMTQuNzc2MTQyIDEwLDE0LjUgViAxMi4yNTQgQyA5Ljk5OTgwMTgsMTEuODA3NzkyIDkuNzA0MDA0MywxMS40MTU3MDcgOS4yNzUsMTEuMjkzIFoiIGZpbGw9IiNmZmYiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgLTEpIi8+PC9nPjwvZz48L3N2Zz4='); --icon-PublicColor: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTQiIHdpZHRoPSIxNiI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIG9wYWNpdHk9Ii43ODMiPjxwYXRoIGQ9Ik0gMTUuMjc1LDEwLjI5MyAxMy4wODcsOS42NjggQyAxMi43MjY0NzgsOS41NjQ5MTc4IDEyLjQ1MzkyLDkuMjY4ODEwNSAxMi4zODEsOC45MDEgTCAxMi4yNDcsOC4yMjYgQyAxMy4zMTQxMiw3LjczODMwNjEgMTMuOTk5MDAyLDYuNjczMjgxNyAxNCw1LjUgViA0LjEyNiBDIDE0LjAyNTE5OCwyLjQ3MzAwMzMgMTIuNzQyNTA2LDEuMDk0MTUzNCAxMS4wOTIsMSA5Ljg3OTM3MDksMC45NjM0MTA4MiA4Ljc2NDA2NTUsMS42NjA3NzI3IDguMjY2LDIuNzY3IDguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4LDQuMjgzNjI0NyA5LDUuMTI2IFYgNi41IEMgOC45OTgyMDc3LDYuODYzNzcwNiA4Ljk0NjA0NTYsNy4yMjU1NDA0IDguODQ1LDcuNTc1IDkuMTA0MjMyNSw3Ljg0Njk0MjMgOS40MTIyMzk1LDguMDY3NzcxMSA5Ljc1Myw4LjIyNiBMIDkuNjE5LDguOSBDIDkuNTQ2MDc5Niw5LjI2NzgxMDUgOS4yNzM1MjE5LDkuNTYzOTE3OCA4LjkxMyw5LjY2NyBMIDguMDcsOS45MDggOS41NSwxMC4zMzEgYyAwLjg1Njk2NCwwLjI0NzU1NCAxLjQ0NzcwNiwxLjAzMDk5OSAxLjQ1LDEuOTIzIFYgMTQuNSBjIC0wLjAwMTcsMC4xNzA3MiAtMC4wMzI3OCwwLjMzOTg3MSAtMC4wOTIsMC41IEggMTUuNSBjIDAuMjc2MTQyLDAgMC41LC0wLjIyMzg1OCAwLjUsLTAuNSB2IC0zLjI0NiBjIC0xLjk4ZS00LC0wLjQ0NjIwOCAtMC4yOTU5OTYsLTAuODM4MjkzIC0wLjcyNSwtMC45NjEgeiIgZmlsbD0iI2U2YTExNyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNIDkuMjc1LDExLjI5MyA3LjA4NywxMC42NjggQyA2LjcyNjIwMjMsMTAuNTY0NzU0IDYuNDUzNTgzLDEwLjI2ODE5NCA2LjM4MSw5LjkgTCA2LjI0Nyw5LjIyNSBDIDcuMzEzNzkxNCw4LjczNzQ1NjkgNy45OTg2MTEyLDcuNjcyOTE5NSA4LDYuNSBWIDUuMTI2IEMgOC4wMjUxOTc2LDMuNDczMDAzMyA2Ljc0MjUwNTUsMi4wOTQxNTM0IDUuMDkyLDIgNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgzIDIuOTEwOTUzMiwyLjg0NTQ3ODUgMi4zMjgyODYzLDMuNDEwODA4NiAxLjk5OTYxODIsNC4xODgxNTA5IDIsNSB2IDEuNSBjIDkuOTc5ZS00LDEuMTczMjgxNyAwLjY4NTg3OTcsMi4yMzgzMDYyIDEuNzUzLDIuNzI2IEwgMy42MTksOS45IEMgMy41NDYwOCwxMC4yNjc4MTEgMy4yNzM1MjE5LDEwLjU2MzkxOCAyLjkxMywxMC42NjcgTCAwLjcyNSwxMS4yOTIgQyAwLjI5NTYzOTI2LDExLjQxNDgwOSAtMi40ODE3NzE2ZS00LDExLjgwNzQyMSAwLDEyLjI1NCBWIDE0LjUgQyAwLDE0Ljc3NjE0MiAwLjIyMzg1NzYzLDE1IDAuNSwxNSBoIDkgQyA5Ljc3NjE0MjQsMTUgMTAsMTQuNzc2MTQyIDEwLDE0LjUgViAxMi4yNTQgQyA5Ljk5OTgwMTgsMTEuODA3NzkyIDkuNzA0MDA0MywxMS40MTU3MDcgOS4yNzUsMTEuMjkzIFoiIGZpbGw9IiNmZmYiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgLTEpIi8+PC9nPjwvZz48L3N2Zz4=');
--icon-PublicFilled: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIGZpbGw9IiMxNkIzNzgiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTE1LjI3NSwxMC4yOTMgTDEzLjA4Nyw5LjY2OCBDMTIuNzI2NDc4MSw5LjU2NDkxNzggMTIuNDUzOTIwNCw5LjI2ODgxMDUgMTIuMzgxLDguOTAxIEwxMi4yNDcsOC4yMjYgQzEzLjMxNDEyMDMsNy43MzgzMDYxNSAxMy45OTkwMDIxLDYuNjczMjgxNyAxNCw1LjUgTDE0LDQuMTI2IEMxNC4wMjUxOTc2LDIuNDczMDAzMjUgMTIuNzQyNTA1NSwxLjA5NDE1MzM3IDExLjA5MiwxIEM5Ljg3OTM3MDksMC45NjM0MTA4MjIgOC43NjQwNjU1LDEuNjYwNzcyNjkgOC4yNjYsMi43NjcgQzguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4NCw0LjI4MzYyNDc0IDksNS4xMjYgTDksNi41IEM4Ljk5ODIwNzc0LDYuODYzNzcwNTcgOC45NDYwNDU1OCw3LjIyNTU0MDM4IDguODQ1LDcuNTc1IEM5LjEwNDIzMjUzLDcuODQ2OTQyMjYgOS40MTIyMzk1Myw4LjA2Nzc3MTA2IDkuNzUzLDguMjI2IEw5LjYxOSw4LjkgQzkuNTQ2MDc5NTcsOS4yNjc4MTA1IDkuMjczNTIxODcsOS41NjM5MTc4IDguOTEzLDkuNjY3IEw4LjA3LDkuOTA4IEw5LjU1LDEwLjMzMSBDMTAuNDA2OTY0MywxMC41Nzg1NTM2IDEwLjk5NzcwNTksMTEuMzYxOTk5MyAxMSwxMi4yNTQgTDExLDE0LjUgQzEwLjk5ODM0MjgsMTQuNjcwNzE5OCAxMC45NjcyMTg5LDE0LjgzOTg3MTUgMTAuOTA4LDE1IEwxNS41LDE1IEMxNS43NzYxNDI0LDE1IDE2LDE0Ljc3NjE0MjQgMTYsMTQuNSBMMTYsMTEuMjU0IEMxNS45OTk4MDE4LDEwLjgwNzc5MTggMTUuNzA0MDA0MywxMC40MTU3MDcyIDE1LjI3NSwxMC4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNOS4yNzUsMTEuMjkzIEw3LjA4NywxMC42NjggQzYuNzI2MjAyMzIsMTAuNTY0NzUzOSA2LjQ1MzU4MywxMC4yNjgxOTM1IDYuMzgxLDkuOSBMNi4yNDcsOS4yMjUgQzcuMzEzNzkxNDEsOC43Mzc0NTY5MyA3Ljk5ODYxMTI1LDcuNjcyOTE5NTQgOCw2LjUgTDgsNS4xMjYgQzguMDI1MTk3NTYsMy40NzMwMDMyNSA2Ljc0MjUwNTQ3LDIuMDk0MTUzMzcgNS4wOTIsMiBDNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgyNyAyLjkxMDk1MzIzLDIuODQ1NDc4NDYgQzIuMzI4Mjg2MjUsMy40MTA4MDg2NCAxLjk5OTYxODE2LDQuMTg4MTUwOTUgMiw1IEwyLDYuNSBDMi4wMDA5OTc5MSw3LjY3MzI4MTcgMi42ODU4Nzk3NCw4LjczODMwNjE1IDMuNzUzLDkuMjI2IEwzLjYxOSw5LjkgQzMuNTQ2MDc5NTcsMTAuMjY3ODEwNSAzLjI3MzUyMTg3LDEwLjU2MzkxNzggMi45MTMsMTAuNjY3IEwwLjcyNSwxMS4yOTIgQzAuMjk1NjM5MjYyLDExLjQxNDgwOTEgLTAuMDAwMjQ4MTc3MTU3LDExLjgwNzQyMTIgMCwxMi4yNTQgTDAsMTQuNSBDMy4zODE3NjU4MWUtMTcsMTQuNzc2MTQyNCAwLjIyMzg1NzYyNSwxNSAwLjUsMTUgTDkuNSwxNSBDOS43NzYxNDIzNywxNSAxMCwxNC43NzYxNDI0IDEwLDE0LjUgTDEwLDEyLjI1NCBDOS45OTk4MDE3NywxMS44MDc3OTE4IDkuNzA0MDA0MzEsMTEuNDE1NzA3MiA5LjI3NSwxMS4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48L2c+PC9nPjwvc3ZnPg=='); --icon-PublicFilled: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIGZpbGw9IiMxNkIzNzgiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTE1LjI3NSwxMC4yOTMgTDEzLjA4Nyw5LjY2OCBDMTIuNzI2NDc4MSw5LjU2NDkxNzggMTIuNDUzOTIwNCw5LjI2ODgxMDUgMTIuMzgxLDguOTAxIEwxMi4yNDcsOC4yMjYgQzEzLjMxNDEyMDMsNy43MzgzMDYxNSAxMy45OTkwMDIxLDYuNjczMjgxNyAxNCw1LjUgTDE0LDQuMTI2IEMxNC4wMjUxOTc2LDIuNDczMDAzMjUgMTIuNzQyNTA1NSwxLjA5NDE1MzM3IDExLjA5MiwxIEM5Ljg3OTM3MDksMC45NjM0MTA4MjIgOC43NjQwNjU1LDEuNjYwNzcyNjkgOC4yNjYsMi43NjcgQzguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4NCw0LjI4MzYyNDc0IDksNS4xMjYgTDksNi41IEM4Ljk5ODIwNzc0LDYuODYzNzcwNTcgOC45NDYwNDU1OCw3LjIyNTU0MDM4IDguODQ1LDcuNTc1IEM5LjEwNDIzMjUzLDcuODQ2OTQyMjYgOS40MTIyMzk1Myw4LjA2Nzc3MTA2IDkuNzUzLDguMjI2IEw5LjYxOSw4LjkgQzkuNTQ2MDc5NTcsOS4yNjc4MTA1IDkuMjczNTIxODcsOS41NjM5MTc4IDguOTEzLDkuNjY3IEw4LjA3LDkuOTA4IEw5LjU1LDEwLjMzMSBDMTAuNDA2OTY0MywxMC41Nzg1NTM2IDEwLjk5NzcwNTksMTEuMzYxOTk5MyAxMSwxMi4yNTQgTDExLDE0LjUgQzEwLjk5ODM0MjgsMTQuNjcwNzE5OCAxMC45NjcyMTg5LDE0LjgzOTg3MTUgMTAuOTA4LDE1IEwxNS41LDE1IEMxNS43NzYxNDI0LDE1IDE2LDE0Ljc3NjE0MjQgMTYsMTQuNSBMMTYsMTEuMjU0IEMxNS45OTk4MDE4LDEwLjgwNzc5MTggMTUuNzA0MDA0MywxMC40MTU3MDcyIDE1LjI3NSwxMC4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNOS4yNzUsMTEuMjkzIEw3LjA4NywxMC42NjggQzYuNzI2MjAyMzIsMTAuNTY0NzUzOSA2LjQ1MzU4MywxMC4yNjgxOTM1IDYuMzgxLDkuOSBMNi4yNDcsOS4yMjUgQzcuMzEzNzkxNDEsOC43Mzc0NTY5MyA3Ljk5ODYxMTI1LDcuNjcyOTE5NTQgOCw2LjUgTDgsNS4xMjYgQzguMDI1MTk3NTYsMy40NzMwMDMyNSA2Ljc0MjUwNTQ3LDIuMDk0MTUzMzcgNS4wOTIsMiBDNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgyNyAyLjkxMDk1MzIzLDIuODQ1NDc4NDYgQzIuMzI4Mjg2MjUsMy40MTA4MDg2NCAxLjk5OTYxODE2LDQuMTg4MTUwOTUgMiw1IEwyLDYuNSBDMi4wMDA5OTc5MSw3LjY3MzI4MTcgMi42ODU4Nzk3NCw4LjczODMwNjE1IDMuNzUzLDkuMjI2IEwzLjYxOSw5LjkgQzMuNTQ2MDc5NTcsMTAuMjY3ODEwNSAzLjI3MzUyMTg3LDEwLjU2MzkxNzggMi45MTMsMTAuNjY3IEwwLjcyNSwxMS4yOTIgQzAuMjk1NjM5MjYyLDExLjQxNDgwOTEgLTAuMDAwMjQ4MTc3MTU3LDExLjgwNzQyMTIgMCwxMi4yNTQgTDAsMTQuNSBDMy4zODE3NjU4MWUtMTcsMTQuNzc2MTQyNCAwLjIyMzg1NzYyNSwxNSAwLjUsMTUgTDkuNSwxNSBDOS43NzYxNDIzNywxNSAxMCwxNC43NzYxNDI0IDEwLDE0LjUgTDEwLDEyLjI1NCBDOS45OTk4MDE3NywxMS44MDc3OTE4IDkuNzA0MDA0MzEsMTEuNDE1NzA3MiA5LjI3NSwxMS4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48L2c+PC9nPjwvc3ZnPg==');
--icon-Question: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iOCIgY3k9IjgiIHI9IjcuNSIgc3Ryb2tlPSIjMTZCMzc4Ii8+PHBhdGggZD0iTTcuMTI0NiAxMC4zNDA5VjEwLjI4NTVDNy4xMzA3NiA5LjY5NzY4IDcuMTkyMzEgOS4yMjk4OCA3LjMwOTI2IDguODgyMUM3LjQyNjIxIDguNTM0MzMgNy41OTI0IDguMjUyNzIgNy44MDc4NCA4LjAzNzI5QzguMDIzMjcgNy44MjE4NSA4LjI4MTggNy42MjMzNCA4LjU4MzQxIDcuNDQxNzZDOC43NjQ5OSA3LjMzMDk3IDguOTI4MSA3LjIwMDE3IDkuMDcyNzUgNy4wNDkzNkM5LjIxNzQgNi44OTU0OCA5LjMzMTI4IDYuNzE4NTEgOS40MTQzNyA2LjUxODQ3QzkuNTAwNTUgNi4zMTg0MiA5LjU0MzYzIDYuMDk2ODMgOS41NDM2MyA1Ljg1MzY5QzkuNTQzNjMgNS41NTIwOCA5LjQ3Mjg1IDUuMjkwNDggOS4zMzEyOCA1LjA2ODg5QzkuMTg5NyA0Ljg0NzMgOS4wMDA0MyA0LjY3NjQ5IDguNzYzNDUgNC41NTY0NkM4LjUyNjQ3IDQuNDM2NDMgOC4yNjMzMyA0LjM3NjQyIDcuOTc0MDMgNC4zNzY0MkM3LjcyMTY2IDQuMzc2NDIgNy40Nzg1MyA0LjQyODc0IDcuMjQ0NjMgNC41MzMzOEM3LjAxMDczIDQuNjM4MDIgNi44MTUzIDQuODAyNjggNi42NTgzNCA1LjAyNzM0QzYuNTAxMzggNS4yNTIwMSA2LjQxMDU5IDUuNTQ1OTMgNi4zODU5NiA1LjkwOTA5SDUuMjIyNjFDNS4yNDcyMyA1LjM4NTg5IDUuMzgyNjUgNC45MzgwOSA1LjYyODg2IDQuNTY1N0M1Ljg3ODE1IDQuMTkzMyA2LjIwNTkyIDMuOTA4NjIgNi42MTIxNyAzLjcxMTY1QzcuMDIxNSAzLjUxNDY4IDcuNDc1NDUgMy40MTYxOSA3Ljk3NDAzIDMuNDE2MTlDOC41MTU3IDMuNDE2MTkgOC45ODY1OCAzLjUyMzkxIDkuMzg2NjcgMy43MzkzNUM5Ljc4OTg1IDMuOTU0NzggMTAuMTAwNyA0LjI1MDI0IDEwLjMxOTIgNC42MjU3MUMxMC41NDA4IDUuMDAxMTggMTAuNjUxNiA1LjQyODk4IDEwLjY1MTYgNS45MDkwOUMxMC42NTE2IDYuMjQ3NjMgMTAuNTk5MyA2LjU1Mzg2IDEwLjQ5NDYgNi44Mjc3N0MxMC4zOTMxIDcuMTAxNjggMTAuMjQ1MyA3LjM0NjM1IDEwLjA1MTQgNy41NjE3OUM5Ljg2MDYzIDcuNzc3MjMgOS42Mjk4MSA3Ljk2ODA0IDkuMzU4OTggOC4xMzQyM0M5LjA4ODE0IDguMzAzNSA4Ljg3MTE3IDguNDgyMDEgOC43MDgwNSA4LjY2OTc0QzguNTQ0OTQgOC44NTQ0IDguNDI2NDUgOS4wNzQ0NiA4LjM1MjU4IDkuMzI5OUM4LjI3ODcyIDkuNTg1MzUgOC4yMzg3MSA5LjkwMzg4IDguMjMyNTUgMTAuMjg1NVYxMC4zNDA5SDcuMTI0NlpNNy43MTU1MSAxMy4wNzM5QzcuNDg3NzYgMTMuMDczOSA3LjI5MjMzIDEyLjk5MjMgNy4xMjkyMiAxMi44MjkyQzYuOTY2MSAxMi42NjYxIDYuODg0NTQgMTIuNDcwNiA2Ljg4NDU0IDEyLjI0MjlDNi44ODQ1NCAxMi4wMTUyIDYuOTY2MSAxMS44MTk3IDcuMTI5MjIgMTEuNjU2NkM3LjI5MjMzIDExLjQ5MzUgNy40ODc3NiAxMS40MTE5IDcuNzE1NTEgMTEuNDExOUM3Ljk0MzI2IDExLjQxMTkgOC4xMzg2OSAxMS40OTM1IDguMzAxOCAxMS42NTY2QzguNDY0OTIgMTEuODE5NyA4LjU0NjQ4IDEyLjAxNTIgOC41NDY0OCAxMi4yNDI5QzguNTQ2NDggMTIuMzkzNyA4LjUwODAxIDEyLjUzMjIgOC40MzEwNiAxMi42NTg0QzguMzU3MiAxMi43ODQ2IDguMjU3MTggMTIuODg2MSA4LjEzMDk5IDEyLjk2MzFDOC4wMDc4OSAxMy4wMzY5IDcuODY5MzkgMTMuMDczOSA3LjcxNTUxIDEzLjA3MzlaIiBmaWxsPSIjMTZCMzc4Ii8+PC9zdmc+');
--icon-Redo: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuMzU1MzI4LDUgTDYuNTAwMTAyMzgsNSBDNi43NzYyNDQ3Niw1IDcuMDAwMTAyMzgsNS4yMjM4NTc2MyA3LjAwMDEwMjM4LDUuNSBDNy4wMDAxMDIzOCw1Ljc3NjE0MjM3IDYuNzc2MjQ0NzYsNiA2LjUwMDEwMjM4LDYgTDEuNTEwMjEyMzgsNiBDMS40ODY4NTQ0NCw2LjAwMDQ5MTk4IDEuNDYzMzU4OTcsNS45OTkzNDQxNCAxLjQzOTg5NjI3LDUuOTk2NDk5MjUgQzEuMzYwMzg0NTQsNS45ODY4NzA1OCAxLjI4NjYwNzQzLDUuOTU4NjY1NjMgMS4yMjMwNzYwNCw1LjkxNjMwNDg0IEMxLjA5OTMxODAxLDUuODM0MTc1NDcgMS4wMjE5NjcwOSw1LjcwMzQ3ODggMS4wMDQwMTY1Niw1LjU2Mjg2NjI5IEMxLjAwMTkxMjc3LDUuNTQ2MDk2MDIgMS4wMDA2Mzk1Niw1LjUyOTA2NzQgMS4wMDAyMzk0Miw1LjUxMTgyMjkyIEMwLjk5OTk1NjM3MSw1LjUwNDI3MzIxIDAuOTk5OTQyNjg0LDUuNDk2NzA0NiAxLjAwMDEwMjM4LDUuNDg5MTI2OTMgTDEuMDAwMTAyMzgsMC41IEMxLjAwMDEwMjM4LDAuMjIzODU3NjI1IDEuMjIzOTYwMDEsMCAxLjUwMDEwMjM4LDAgQzEuNzc2MjQ0NzYsMCAyLjAwMDEwMjM4LDAuMjIzODU3NjI1IDIuMDAwMTAyMzgsMC41IEwyLjAwMDEwMjM4LDMuNzc0NjkzMTYgQzMuMzY4NjQzMjgsMi4wMzk0MDM0MyA1LjMyOTE3Nzk4LDEgNy41MDAxMDIzOCwxIEMxMS42NDIyNDQ4LDEgMTUuMDAwMTAyNCw0LjM1Nzg1NzYzIDE1LjAwMDEwMjQsOC41IEMxNS4wMDAxMDI0LDEyLjY0MjE0MjQgMTEuNjQyMjQ0OCwxNiA3LjUwMDEwMjM4LDE2IEM3LjIyMzk2MDAxLDE2IDcuMDAwMTAyMzgsMTUuNzc2MTQyNCA3LjAwMDEwMjM4LDE1LjUgQzcuMDAwMTAyMzgsMTUuMjIzODU3NiA3LjIyMzk2MDAxLDE1IDcuNTAwMTAyMzgsMTUgQzExLjA4OTk2LDE1IDE0LjAwMDEwMjQsMTIuMDg5ODU3NiAxNC4wMDAxMDI0LDguNSBDMTQuMDAwMTAyNCw0LjkxMDE0MjM3IDExLjA4OTk2LDIgNy41MDAxMDIzOCwyIEM1LjQxNTg0ODkyLDIgMy41NDU2NTQwMiwzLjEzMDY0MDcgMi4zNTUzMjgsNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHRyYW5zZm9ybT0ibWF0cml4KC0xIDAgMCAxIDE2IDApIi8+PC9zdmc+'); --icon-Redo: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuMzU1MzI4LDUgTDYuNTAwMTAyMzgsNSBDNi43NzYyNDQ3Niw1IDcuMDAwMTAyMzgsNS4yMjM4NTc2MyA3LjAwMDEwMjM4LDUuNSBDNy4wMDAxMDIzOCw1Ljc3NjE0MjM3IDYuNzc2MjQ0NzYsNiA2LjUwMDEwMjM4LDYgTDEuNTEwMjEyMzgsNiBDMS40ODY4NTQ0NCw2LjAwMDQ5MTk4IDEuNDYzMzU4OTcsNS45OTkzNDQxNCAxLjQzOTg5NjI3LDUuOTk2NDk5MjUgQzEuMzYwMzg0NTQsNS45ODY4NzA1OCAxLjI4NjYwNzQzLDUuOTU4NjY1NjMgMS4yMjMwNzYwNCw1LjkxNjMwNDg0IEMxLjA5OTMxODAxLDUuODM0MTc1NDcgMS4wMjE5NjcwOSw1LjcwMzQ3ODggMS4wMDQwMTY1Niw1LjU2Mjg2NjI5IEMxLjAwMTkxMjc3LDUuNTQ2MDk2MDIgMS4wMDA2Mzk1Niw1LjUyOTA2NzQgMS4wMDAyMzk0Miw1LjUxMTgyMjkyIEMwLjk5OTk1NjM3MSw1LjUwNDI3MzIxIDAuOTk5OTQyNjg0LDUuNDk2NzA0NiAxLjAwMDEwMjM4LDUuNDg5MTI2OTMgTDEuMDAwMTAyMzgsMC41IEMxLjAwMDEwMjM4LDAuMjIzODU3NjI1IDEuMjIzOTYwMDEsMCAxLjUwMDEwMjM4LDAgQzEuNzc2MjQ0NzYsMCAyLjAwMDEwMjM4LDAuMjIzODU3NjI1IDIuMDAwMTAyMzgsMC41IEwyLjAwMDEwMjM4LDMuNzc0NjkzMTYgQzMuMzY4NjQzMjgsMi4wMzk0MDM0MyA1LjMyOTE3Nzk4LDEgNy41MDAxMDIzOCwxIEMxMS42NDIyNDQ4LDEgMTUuMDAwMTAyNCw0LjM1Nzg1NzYzIDE1LjAwMDEwMjQsOC41IEMxNS4wMDAxMDI0LDEyLjY0MjE0MjQgMTEuNjQyMjQ0OCwxNiA3LjUwMDEwMjM4LDE2IEM3LjIyMzk2MDAxLDE2IDcuMDAwMTAyMzgsMTUuNzc2MTQyNCA3LjAwMDEwMjM4LDE1LjUgQzcuMDAwMTAyMzgsMTUuMjIzODU3NiA3LjIyMzk2MDAxLDE1IDcuNTAwMTAyMzgsMTUgQzExLjA4OTk2LDE1IDE0LjAwMDEwMjQsMTIuMDg5ODU3NiAxNC4wMDAxMDI0LDguNSBDMTQuMDAwMTAyNCw0LjkxMDE0MjM3IDExLjA4OTk2LDIgNy41MDAxMDIzOCwyIEM1LjQxNTg0ODkyLDIgMy41NDU2NTQwMiwzLjEzMDY0MDcgMi4zNTUzMjgsNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHRyYW5zZm9ybT0ibWF0cml4KC0xIDAgMCAxIDE2IDApIi8+PC9zdmc+');
--icon-Remove: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYsNCBMNiwyLjUgQzYsMi4yMjM4NTc2MyA2LjIyMzg1NzYzLDIgNi41LDIgTDkuNSwyIEM5Ljc3NjE0MjM3LDIgMTAsMi4yMjM4NTc2MyAxMCwyLjUgTDEwLDQgTDEzLjUsNCBDMTMuNzc2MTQyNCw0IDE0LDQuMjIzODU3NjMgMTQsNC41IEMxNCw0Ljc3NjE0MjM3IDEzLjc3NjE0MjQsNSAxMy41LDUgTDIuNSw1IEMyLjIyMzg1NzYzLDUgMiw0Ljc3NjE0MjM3IDIsNC41IEMyLDQuMjIzODU3NjMgMi4yMjM4NTc2Myw0IDIuNSw0IEw2LDQgWiBNNyw0IEw5LDQgTDksMyBMNywzIEw3LDQgWiBNMTEsNi41IEMxMSw2LjIyMzg1NzYzIDExLjIyMzg1NzYsNiAxMS41LDYgQzExLjc3NjE0MjQsNiAxMiw2LjIyMzg1NzYzIDEyLDYuNSBMMTIsMTIuNSBDMTIsMTMuMzI4NDI3MSAxMS4zMjg0MjcxLDE0IDEwLjUsMTQgTDUuNSwxNCBDNC42NzE1NzI4OCwxNCA0LDEzLjMyODQyNzEgNCwxMi41IEw0LDYuNSBDNCw2LjIyMzg1NzYzIDQuMjIzODU3NjMsNiA0LjUsNiBDNC43NzYxNDIzNyw2IDUsNi4yMjM4NTc2MyA1LDYuNSBMNSwxMi41IEM1LDEyLjc3NjE0MjQgNS4yMjM4NTc2MywxMyA1LjUsMTMgTDEwLjUsMTMgQzEwLjc3NjE0MjQsMTMgMTEsMTIuNzc2MTQyNCAxMSwxMi41IEwxMSw2LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-Remove: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYsNCBMNiwyLjUgQzYsMi4yMjM4NTc2MyA2LjIyMzg1NzYzLDIgNi41LDIgTDkuNSwyIEM5Ljc3NjE0MjM3LDIgMTAsMi4yMjM4NTc2MyAxMCwyLjUgTDEwLDQgTDEzLjUsNCBDMTMuNzc2MTQyNCw0IDE0LDQuMjIzODU3NjMgMTQsNC41IEMxNCw0Ljc3NjE0MjM3IDEzLjc3NjE0MjQsNSAxMy41LDUgTDIuNSw1IEMyLjIyMzg1NzYzLDUgMiw0Ljc3NjE0MjM3IDIsNC41IEMyLDQuMjIzODU3NjMgMi4yMjM4NTc2Myw0IDIuNSw0IEw2LDQgWiBNNyw0IEw5LDQgTDksMyBMNywzIEw3LDQgWiBNMTEsNi41IEMxMSw2LjIyMzg1NzYzIDExLjIyMzg1NzYsNiAxMS41LDYgQzExLjc3NjE0MjQsNiAxMiw2LjIyMzg1NzYzIDEyLDYuNSBMMTIsMTIuNSBDMTIsMTMuMzI4NDI3MSAxMS4zMjg0MjcxLDE0IDEwLjUsMTQgTDUuNSwxNCBDNC42NzE1NzI4OCwxNCA0LDEzLjMyODQyNzEgNCwxMi41IEw0LDYuNSBDNCw2LjIyMzg1NzYzIDQuMjIzODU3NjMsNiA0LjUsNiBDNC43NzYxNDIzNyw2IDUsNi4yMjM4NTc2MyA1LDYuNSBMNSwxMi41IEM1LDEyLjc3NjE0MjQgNS4yMjM4NTc2MywxMyA1LjUsMTMgTDEwLjUsMTMgQzEwLjc3NjE0MjQsMTMgMTEsMTIuNzc2MTQyNCAxMSwxMi41IEwxMSw2LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-RemoveBig: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSA1LjQ4MTQ3MTEsNCA1LjQ5ODQyMDMsMS4wNTkzMjIgQyA1LjUwMDAxMTksMC43ODMxODQyMiA1LjcyMTMyOTQsMC41NTkzMjIwMyA1Ljk5NjMwMTcsMC41NTkzMjIwMyBIIDEwLjE5ODkzIGMgMC4yNzQ5NzIsMCAwLjQ5OTQ3MiwwLjIyMzg2MjE5IDAuNDk3ODgxLDAuNDk5OTk5OTcgTCAxMC42Nzk4NjIsNCAxNS4zNTI5NzcsMy45ODMwNTA4IGMgMC4yNzQ5NzEsLTkuOTczZS00IDAuNDk3ODgxLDAuMjIzODU3NiAwLjQ5Nzg4MSwwLjUgMCwwLjI3NjE0MjQgLTAuMjIyOTA5LDAuNDk5NjgzNyAtMC40OTc4ODEsMC41IEwgMC42MTk3Nzc2OCw1IEMgMC4zNDQ4MDU1OCw1LjAwMDMxNjMgMC4xMjE4OTYzOSw0Ljc3NjE0MjQgMC4xMjE4OTYzOSw0LjUgYyAwLC0wLjI3NjE0MjQgMC4yMjI5MDksLTAuNSAwLjQ5Nzg4MTI5LC0wLjUgeiBNIDYuNDc3MjMzOCw0IEggOS42ODQwOTkgTCA5LjcwMTA0ODIsMS41NTkzMjIgSCA2LjQ5NDE4MyBaIG0gNS41Mjk4NDcyLDMuNjI2OTUxNCBjIC0zLjk4ZS00LC0wLjI3NjE0MjEgMC4yMjI5MDksLTAuNSAwLjQ5Nzg4MSwtMC41IDAuMjc0OTczLDAgMC40OTc0ODIsMC4yMjM4NTggMC40OTc4ODIsMC41IGwgMC4wMDkyLDYuMzQ2MjQwNiBjIDAuMDAxMiwwLjgyODQyNiAtMC42Njg3MjgsMS41IC0xLjQ5MzY0NCwxLjUgSCA0LjQ2MjQxMjYgYyAtMC44MjQ5MTY4LDAgLTEuNDkyNDQ5NiwtMC42NzE1NzQgLTEuNDkzNjQ0LC0xLjUgbCAtMC4wMDkxNSwtNi4zNDYyNDA2IGMgLTMuOTgxZS00LC0wLjI3NjE0MjEgMC4yMjI5MDkxLC0wLjUgMC40OTc4ODEzLC0wLjUgMC4yNzQ5NzIzLDAgMC40OTc0ODMzLDAuMjIzODU3OSAwLjQ5Nzg4MTQsMC41IGwgMC4wMDkxNSw2LjM0NjI0MDYgYyAzLjk4MWUtNCwwLjI3NjE0MSAwLjIyMjkwOTEsMC41IDAuNDk3ODgxMywwLjUgaCA3LjA1NTk0MDQgYyAwLjI3NDk3MiwwIDAuNDk4Mjc5LC0wLjIyMzg1OSAwLjQ5Nzg4MSwtMC41IHoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-RemoveBig: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSA1LjQ4MTQ3MTEsNCA1LjQ5ODQyMDMsMS4wNTkzMjIgQyA1LjUwMDAxMTksMC43ODMxODQyMiA1LjcyMTMyOTQsMC41NTkzMjIwMyA1Ljk5NjMwMTcsMC41NTkzMjIwMyBIIDEwLjE5ODkzIGMgMC4yNzQ5NzIsMCAwLjQ5OTQ3MiwwLjIyMzg2MjE5IDAuNDk3ODgxLDAuNDk5OTk5OTcgTCAxMC42Nzk4NjIsNCAxNS4zNTI5NzcsMy45ODMwNTA4IGMgMC4yNzQ5NzEsLTkuOTczZS00IDAuNDk3ODgxLDAuMjIzODU3NiAwLjQ5Nzg4MSwwLjUgMCwwLjI3NjE0MjQgLTAuMjIyOTA5LDAuNDk5NjgzNyAtMC40OTc4ODEsMC41IEwgMC42MTk3Nzc2OCw1IEMgMC4zNDQ4MDU1OCw1LjAwMDMxNjMgMC4xMjE4OTYzOSw0Ljc3NjE0MjQgMC4xMjE4OTYzOSw0LjUgYyAwLC0wLjI3NjE0MjQgMC4yMjI5MDksLTAuNSAwLjQ5Nzg4MTI5LC0wLjUgeiBNIDYuNDc3MjMzOCw0IEggOS42ODQwOTkgTCA5LjcwMTA0ODIsMS41NTkzMjIgSCA2LjQ5NDE4MyBaIG0gNS41Mjk4NDcyLDMuNjI2OTUxNCBjIC0zLjk4ZS00LC0wLjI3NjE0MjEgMC4yMjI5MDksLTAuNSAwLjQ5Nzg4MSwtMC41IDAuMjc0OTczLDAgMC40OTc0ODIsMC4yMjM4NTggMC40OTc4ODIsMC41IGwgMC4wMDkyLDYuMzQ2MjQwNiBjIDAuMDAxMiwwLjgyODQyNiAtMC42Njg3MjgsMS41IC0xLjQ5MzY0NCwxLjUgSCA0LjQ2MjQxMjYgYyAtMC44MjQ5MTY4LDAgLTEuNDkyNDQ5NiwtMC42NzE1NzQgLTEuNDkzNjQ0LC0xLjUgbCAtMC4wMDkxNSwtNi4zNDYyNDA2IGMgLTMuOTgxZS00LC0wLjI3NjE0MjEgMC4yMjI5MDkxLC0wLjUgMC40OTc4ODEzLC0wLjUgMC4yNzQ5NzIzLDAgMC40OTc0ODMzLDAuMjIzODU3OSAwLjQ5Nzg4MTQsMC41IGwgMC4wMDkxNSw2LjM0NjI0MDYgYyAzLjk4MWUtNCwwLjI3NjE0MSAwLjIyMjkwOTEsMC41IDAuNDk3ODgxMywwLjUgaCA3LjA1NTk0MDQgYyAwLjI3NDk3MiwwIDAuNDk4Mjc5LC0wLjIyMzg1OSAwLjQ5Nzg4MSwtMC41IHoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7.5" stroke="#16B378"/>
<path d="M7.1246 10.3409V10.2855C7.13076 9.69768 7.19231 9.22988 7.30926 8.8821C7.42621 8.53433 7.5924 8.25272 7.80784 8.03729C8.02327 7.82185 8.2818 7.62334 8.58341 7.44176C8.76499 7.33097 8.9281 7.20017 9.07275 7.04936C9.2174 6.89548 9.33128 6.71851 9.41437 6.51847C9.50055 6.31842 9.54363 6.09683 9.54363 5.85369C9.54363 5.55208 9.47285 5.29048 9.33128 5.06889C9.1897 4.8473 9.00043 4.67649 8.76345 4.55646C8.52647 4.43643 8.26333 4.37642 7.97403 4.37642C7.72166 4.37642 7.47853 4.42874 7.24463 4.53338C7.01073 4.63802 6.8153 4.80268 6.65834 5.02734C6.50138 5.25201 6.41059 5.54593 6.38596 5.90909H5.22261C5.24723 5.38589 5.38265 4.93809 5.62886 4.5657C5.87815 4.1933 6.20592 3.90862 6.61217 3.71165C7.0215 3.51468 7.47545 3.41619 7.97403 3.41619C8.5157 3.41619 8.98658 3.52391 9.38667 3.73935C9.78985 3.95478 10.1007 4.25024 10.3192 4.62571C10.5408 5.00118 10.6516 5.42898 10.6516 5.90909C10.6516 6.24763 10.5993 6.55386 10.4946 6.82777C10.3931 7.10168 10.2453 7.34635 10.0514 7.56179C9.86063 7.77723 9.62981 7.96804 9.35898 8.13423C9.08814 8.3035 8.87117 8.48201 8.70805 8.66974C8.54494 8.8544 8.42645 9.07446 8.35258 9.3299C8.27872 9.58535 8.23871 9.90388 8.23255 10.2855V10.3409H7.1246ZM7.71551 13.0739C7.48776 13.0739 7.29233 12.9923 7.12922 12.8292C6.9661 12.6661 6.88454 12.4706 6.88454 12.2429C6.88454 12.0152 6.9661 11.8197 7.12922 11.6566C7.29233 11.4935 7.48776 11.4119 7.71551 11.4119C7.94326 11.4119 8.13869 11.4935 8.3018 11.6566C8.46492 11.8197 8.54648 12.0152 8.54648 12.2429C8.54648 12.3937 8.50801 12.5322 8.43106 12.6584C8.3572 12.7846 8.25718 12.8861 8.13099 12.9631C8.00789 13.0369 7.86939 13.0739 7.71551 13.0739Z" fill="#16B378"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -24,13 +24,6 @@ describe('AttachedCustomWidget', function () {
let widgetServerUrl = ''; let widgetServerUrl = '';
// Holds widgets manifest content. // Holds widgets manifest content.
let widgets: ICustomWidget[] = []; let widgets: ICustomWidget[] = [];
// Switches widget manifest url
async function useManifest(url: string) {
await server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : '');
await driver.executeAsyncScript(
(done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done()
);
}
async function buildWidgetServer(){ async function buildWidgetServer(){
// Create simple widget server that serves manifest.json file, some widgets and some error pages. // Create simple widget server that serves manifest.json file, some widgets and some error pages.
@ -69,12 +62,11 @@ describe('AttachedCustomWidget', function () {
before(async function () { before(async function () {
await buildWidgetServer(); await buildWidgetServer();
oldEnv = new EnvironmentSnapshot(); oldEnv = new EnvironmentSnapshot();
process.env.GRIST_WIDGET_LIST_URL = `${widgetServerUrl}${manifestEndpoint}`;
process.env.PERMITTED_CUSTOM_WIDGETS = "calendar"; process.env.PERMITTED_CUSTOM_WIDGETS = "calendar";
await server.restart(); await server.restart();
await useManifest(manifestEndpoint);
const session = await gu.session().login(); const session = await gu.session().login();
await session.tempDoc(cleanup, 'Hello.grist'); await session.tempDoc(cleanup, 'Hello.grist');
}); });
after(async function () { after(async function () {

@ -145,18 +145,6 @@ describe('BehavioralPrompts', function() {
await assertPromptTitle('Editing Card Layout'); await assertPromptTitle('Editing Card Layout');
}); });
it('should be shown after adding custom view as a new page', async function() {
await gu.addNewPage('Custom', 'Table1');
await assertPromptTitle('Custom Widgets');
await gu.undo();
});
it('should be shown after adding custom section', async function() {
await gu.addNewSection('Custom', 'Table1');
await assertPromptTitle('Custom Widgets');
await gu.undo();
});
describe('for the Add New button', function() { describe('for the Add New button', function() {
it('should not be shown if site is empty', async function() { it('should not be shown if site is empty', async function() {
session = await gu.session().user('user4').login({showTips: true}); session = await gu.session().user('user4').login({showTips: true});

@ -1,25 +1,12 @@
import {safeJsonParse} from 'app/common/gutil'; import {safeJsonParse} from 'app/common/gutil';
import * as chai from 'chai';
import {assert, driver, Key} from 'mocha-webdriver'; import {assert, driver, Key} from 'mocha-webdriver';
import {serveCustomViews, Serving, setAccess} from 'test/nbrowser/customUtil';
import * as gu from 'test/nbrowser/gristUtils'; import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils'; import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import { serveCustomViews, Serving, setAccess } from 'test/nbrowser/customUtil';
import * as chai from 'chai';
chai.config.truncateThreshold = 5000; chai.config.truncateThreshold = 5000;
async function setCustomWidget() {
// if there is a select widget option
if (await driver.find('.test-config-widget-select').isPresent()) {
const selected = await driver.find('.test-config-widget-select .test-select-open').getText();
if (selected != "Custom URL") {
await driver.find('.test-config-widget-select .test-select-open').click();
await driver.findContent('.test-select-menu li', "Custom URL").click();
await gu.waitForServer();
}
}
}
describe('CustomView', function() { describe('CustomView', function() {
this.timeout(20000); this.timeout(20000);
gu.bigScreen(); gu.bigScreen();
@ -49,9 +36,8 @@ describe('CustomView', function() {
await gu.addNewSection('Custom', 'Table1'); await gu.addNewSection('Custom', 'Table1');
// Point to a widget that doesn't immediately call ready. // Point to a widget that doesn't immediately call ready.
await gu.setCustomWidgetUrl(`${serving.url}/deferred-ready`, {openGallery: false});
await gu.toggleSidePanel('right', 'open'); await gu.toggleSidePanel('right', 'open');
await driver.find('.test-config-widget-url').click();
await gu.sendKeys(`${serving.url}/deferred-ready`, Key.ENTER);
// We should have a single iframe. // We should have a single iframe.
assert.equal(await driver.findAll('iframe').then(f => f.length), 1); assert.equal(await driver.findAll('iframe').then(f => f.length), 1);
@ -108,10 +94,8 @@ describe('CustomView', function() {
// Replace the widget with a custom widget that just reads out the data // Replace the widget with a custom widget that just reads out the data
// as JSON. // as JSON.
await driver.find('.test-config-widget').click(); await gu.setCustomWidgetUrl(`${serving.url}/readout`, {openGallery: false});
await setCustomWidget(); await gu.openWidgetPanel();
await driver.find('.test-config-widget-url').click();
await driver.sendKeys(`${serving.url}/readout`, Key.ENTER);
await setAccess(access); await setAccess(access);
await gu.waitForServer(); await gu.waitForServer();
@ -167,10 +151,8 @@ describe('CustomView', function() {
await gu.waitForServer(); await gu.waitForServer();
// Choose the custom view that just reads out data as json // Choose the custom view that just reads out data as json
await driver.find('.test-config-widget').click(); await gu.setCustomWidgetUrl(`${serving.url}/readout`, {openGallery: false});
await setCustomWidget(); await gu.openWidgetPanel();
await driver.find('.test-config-widget-url').click();
await driver.sendKeys(`${serving.url}/readout`, Key.ENTER);
await setAccess(access); await setAccess(access);
await gu.waitForServer(); await gu.waitForServer();
@ -265,7 +247,7 @@ describe('CustomView', function() {
it('allows switching to custom section by clicking inside it', async function() { it('allows switching to custom section by clicking inside it', async function() {
await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click(); await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click();
assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS'); assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS');
assert.equal(await driver.find('.test-config-widget-url').isPresent(), false); assert.equal(await driver.find('.test-config-widget-open-custom-widget-gallery').isPresent(), false);
const iframe = gu.getSection('Friends custom').find('iframe'); const iframe = gu.getSection('Friends custom').find('iframe');
await driver.switchTo().frame(iframe); await driver.switchTo().frame(iframe);
@ -274,24 +256,19 @@ describe('CustomView', function() {
// Check that the right section is active, and its settings visible in the side panel. // Check that the right section is active, and its settings visible in the side panel.
await driver.switchTo().defaultContent(); await driver.switchTo().defaultContent();
assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS Custom'); assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS Custom');
assert.equal(await driver.find('.test-config-widget-url').isPresent(), true); assert.equal(await driver.find('.test-config-widget-open-custom-widget-gallery').isPresent(), true);
// Switch back. // Switch back.
await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click(); await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click();
assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS'); assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS');
assert.equal(await driver.find('.test-config-widget-url').isPresent(), false); assert.equal(await driver.find('.test-config-widget-open-custom-widget-gallery').isPresent(), false);
}); });
it('deals correctly with requests that require full access', async function() { it('deals correctly with requests that require full access', async function() {
// Choose a custom widget that tries to replace all cells in all user tables with 'zap'. // Choose a custom widget that tries to replace all cells in all user tables with 'zap'.
await gu.getSection('Friends Custom').click(); await gu.getSection('Friends Custom').click();
await driver.find('.test-config-widget').click(); await gu.setCustomWidgetUrl(`${serving.url}/zap`);
await setAccess("none"); await gu.openWidgetPanel();
await gu.waitForServer();
await gu.setValue(driver.find('.test-config-widget-url'), '');
await driver.find('.test-config-widget-url').click();
await driver.sendKeys(`${serving.url}/zap`, Key.ENTER);
await setAccess(access); await setAccess(access);
await gu.waitForServer(); await gu.waitForServer();
@ -329,12 +306,10 @@ describe('CustomView', function() {
// The test doc already has a Custom View widget. It just needs to // The test doc already has a Custom View widget. It just needs to
// have a URL set. // have a URL set.
await gu.getSection('TYPES custom').click(); await gu.getSection('TYPES custom').click();
await driver.find('.test-config-widget').click(); await gu.setCustomWidgetUrl(`${serving.url}/types`);
await setCustomWidget();
// If we needed to change widget to Custom URL, make sure access is read table. // If we needed to change widget to Custom URL, make sure access is read table.
await setAccess("read table"); await setAccess("read table");
await driver.find('.test-config-widget-url').click(); await gu.waitForServer();
await driver.sendKeys(`${serving.url}/types`, Key.ENTER);
const iframe = gu.getSection('TYPES custom').find('iframe'); const iframe = gu.getSection('TYPES custom').find('iframe');
await driver.switchTo().frame(iframe); await driver.switchTo().frame(iframe);
@ -480,10 +455,8 @@ describe('CustomView', function() {
await gu.waitForServer(); await gu.waitForServer();
// Select a custom widget that tries to replace all cells in all user tables with 'zap'. // Select a custom widget that tries to replace all cells in all user tables with 'zap'.
await driver.find('.test-config-widget').click(); await gu.setCustomWidgetUrl(`${serving.url}/zap`, {openGallery: false});
await setCustomWidget(); await gu.openWidgetPanel();
await driver.find('.test-config-widget-url').click();
await driver.sendKeys(`${serving.url}/zap`, Key.ENTER);
await setAccess("full"); await setAccess("full");
await gu.waitForServer(); await gu.waitForServer();
@ -537,10 +510,10 @@ describe('CustomView', function() {
const doc = await mainSession.tempDoc(cleanup, 'FetchSelectedOptions.grist', {load: false}); const doc = await mainSession.tempDoc(cleanup, 'FetchSelectedOptions.grist', {load: false});
await mainSession.loadDoc(`/doc/${doc.id}`); await mainSession.loadDoc(`/doc/${doc.id}`);
await gu.toggleSidePanel('right', 'open');
await gu.getSection('TABLE1 Custom').click(); await gu.getSection('TABLE1 Custom').click();
await driver.find('.test-config-widget-url').click(); await gu.setCustomWidgetUrl(`${serving.url}/fetchSelectedOptions`);
await gu.sendKeys(`${serving.url}/fetchSelectedOptions`, Key.ENTER); await gu.openWidgetPanel();
await setAccess("full");
await gu.waitForServer(); await gu.waitForServer();
const expected = { const expected = {
@ -620,8 +593,10 @@ describe('CustomView', function() {
} }
await inFrame(async () => { await inFrame(async () => {
const parsed = await getData(12); await gu.waitToPass(async () => {
assert.deepEqual(parsed, expected); const parsed = await getData(12);
assert.deepEqual(parsed, expected);
}, 1000);
}); });
// Change the access level away from 'full'. // Change the access level away from 'full'.

@ -20,20 +20,46 @@ const widgetEndpoint = '/widget';
const CUSTOM_URL = 'Custom URL'; const CUSTOM_URL = 'Custom URL';
// Create some widgets: // Create some widgets:
const widget1: ICustomWidget = {widgetId: '1', name: 'W1', url: widgetEndpoint + '?name=W1'}; const widget1: ICustomWidget = {
const widget2: ICustomWidget = {widgetId: '2', name: 'W2', url: widgetEndpoint + '?name=W2'}; widgetId: '1',
name: 'W1',
url: widgetEndpoint + '?name=W1',
description: 'Widget 1 description',
authors: [
{
name: 'Developer 1',
},
{
name: 'Developer 2',
},
],
isGristLabsMaintained: true,
lastUpdatedAt: '2024-07-30T00:13:31-04:00',
};
const widget2: ICustomWidget = {
widgetId: '2',
name: 'W2',
url: widgetEndpoint + '?name=W2',
};
const widgetWithTheme: ICustomWidget = { const widgetWithTheme: ICustomWidget = {
widgetId: '3', widgetId: '3',
name: 'WithTheme', name: 'WithTheme',
url: widgetEndpoint + '?name=WithTheme', url: widgetEndpoint + '?name=WithTheme',
isGristLabsMaintained: true,
}; };
const widgetNoPluginApi: ICustomWidget = { const widgetNoPluginApi: ICustomWidget = {
widgetId: '4', widgetId: '4',
name: 'NoPluginApi', name: 'NoPluginApi',
url: widgetEndpoint + '?name=NoPluginApi', url: widgetEndpoint + '?name=NoPluginApi',
isGristLabsMaintained: true,
}; };
const fromAccess = (level: AccessLevel) => const fromAccess = (level: AccessLevel): ICustomWidget => ({
({widgetId: level, name: level, url: widgetEndpoint, accessLevel: level}) as ICustomWidget; widgetId: level,
name: level,
url: widgetEndpoint,
accessLevel: level,
isGristLabsMaintained: true,
});
const widgetNone = fromAccess(AccessLevel.none); const widgetNone = fromAccess(AccessLevel.none);
const widgetRead = fromAccess(AccessLevel.read_table); const widgetRead = fromAccess(AccessLevel.read_table);
const widgetFull = fromAccess(AccessLevel.full); const widgetFull = fromAccess(AccessLevel.full);
@ -51,23 +77,27 @@ describe('CustomWidgets', function () {
gu.bigScreen(); gu.bigScreen();
const cleanup = setupTestSuite(); const cleanup = setupTestSuite();
let oldEnv: EnvironmentSnapshot;
// Holds url for sample widget server. // Holds url for sample widget server.
let widgetServerUrl = ''; let widgetServerUrl = '';
// Switches widget manifest url // Switches widget manifest url
async function useManifest(url: string) { async function useManifest(url: string) {
await server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : ''); await server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : '');
}
async function reloadWidgets() {
await driver.executeAsyncScript( await driver.executeAsyncScript(
(done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done() (done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done()
); );
} }
before(async function () { before(async function () {
if (server.isExternalServer()) { if (server.isExternalServer()) {
this.skip(); this.skip();
} }
// Create simple widget server that serves manifest.json file, some widgets and some error pages. // Create simple widget server that serves manifest.json file, some widgets and some error pages.
const widgetServer = await serveSomething(app => { const widgetServer = await serveSomething(app => {
app.get('/404', (_, res) => res.sendStatus(404).end()); // not found app.get('/404', (_, res) => res.sendStatus(404).end()); // not found
@ -105,32 +135,31 @@ describe('CustomWidgets', function () {
cleanup.addAfterAll(widgetServer.shutdown); cleanup.addAfterAll(widgetServer.shutdown);
widgetServerUrl = widgetServer.url; widgetServerUrl = widgetServer.url;
// Start with valid endpoint and 2 widgets. oldEnv = new EnvironmentSnapshot();
process.env.GRIST_WIDGET_LIST_URL = `${widgetServerUrl}${manifestEndpoint}`;
await server.restart();
// Start with 2 widgets.
widgets = [widget1, widget2]; widgets = [widget1, widget2];
await useManifest(manifestEndpoint);
const session = await gu.session().login(); const session = await gu.session().login();
await session.tempDoc(cleanup, 'Hello.grist'); await session.tempDoc(cleanup, 'Hello.grist');
// Add custom section.
await gu.addNewSection(/Custom/, /Table1/, {selectBy: /TABLE1/});
// Add custom section.
await gu.addNewSection(/Custom/, /Table1/, {customWidget: /Custom URL/, selectBy: /TABLE1/});
}); });
after(async function() { after(async function() {
await server.testingHooks.setWidgetRepositoryUrl(''); await server.testingHooks.setWidgetRepositoryUrl('');
oldEnv.restore();
await server.restart();
}); });
// Open or close widget menu. afterEach(() => gu.checkForErrors());
const toggle = async () => await driver.findWait('.test-config-widget-select .test-select-open', 1000).click();
// Get current value from widget menu. // Get available widgets from widget gallery (must be first opened).
const current = () => driver.find('.test-config-widget-select .test-select-open').getText(); const galleryWidgets = () => driver.findAll('.test-custom-widget-gallery-widget-name', e => e.getText());
// Get options from widget menu (must be first opened).
const options = () => driver.findAll('.test-select-menu li', e => e.getText());
// Select widget from the menu.
const select = async (text: string | RegExp) => {
await driver.findContent('.test-select-menu li', text).click();
await gu.waitForServer();
};
// Get rendered content from custom section. // Get rendered content from custom section.
const content = async () => { const content = async () => {
return gu.doInIframe(await getCustomWidgetFrame(), async ()=>{ return gu.doInIframe(await getCustomWidgetFrame(), async ()=>{
@ -169,19 +198,6 @@ describe('CustomWidgets', function () {
return result === "__undefined__" ? undefined : result; return result === "__undefined__" ? undefined : result;
}); });
} }
// Replace url for the Custom URL widget.
const setUrl = async (url: string) => {
await driver.find('.test-config-widget-url').click();
// First clear textbox.
await gu.sendKeys(await gu.selectAllKey(), Key.DELETE);
if (url) {
await gu.sendKeys(`${widgetServerUrl}${url}`, Key.ENTER);
} else {
await gu.sendKeys(Key.ENTER);
}
};
// Get an URL from the URL textbox.
const getUrl = () => driver.find('.test-config-widget-url').value();
// Get first error message from error toasts. // Get first error message from error toasts.
const getErrorMessage = async () => (await gu.getToasts())[0]; const getErrorMessage = async () => (await gu.getToasts())[0];
// Changes active section to recreate creator panel. // Changes active section to recreate creator panel.
@ -215,8 +231,6 @@ describe('CustomWidgets', function () {
const reject = () => driver.find(".test-config-widget-access-reject").click(); const reject = () => driver.find(".test-config-widget-access-reject").click();
async function enableWidgetsAndShowPanel() { async function enableWidgetsAndShowPanel() {
// Override gristConfig to enable widget list.
await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
// We need to be sure that widget configuration panel is open all the time. // We need to be sure that widget configuration panel is open all the time.
await gu.toggleSidePanel('right', 'open'); await gu.toggleSidePanel('right', 'open');
await recreatePanel(); await recreatePanel();
@ -226,68 +240,65 @@ describe('CustomWidgets', function () {
describe('RightWidgetMenu', () => { describe('RightWidgetMenu', () => {
beforeEach(enableWidgetsAndShowPanel); beforeEach(enableWidgetsAndShowPanel);
it('should show widgets in dropdown', async () => { afterEach(() => gu.checkForErrors());
await gu.toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-pagewidget').click();
await gu.waitForServer();
await driver.find('.test-config-widget').click();
await gu.waitForServer(); // Wait for widgets to load.
// Selectbox should have select label. it('should show button to open gallery', async () => {
assert.equal(await current(), CUSTOM_URL); const button = await driver.find('.test-config-widget-open-custom-widget-gallery');
assert.equal(await button.getText(), 'Custom URL');
// There should be 3 options (together with Custom URL) await button.click();
await toggle(); assert.isTrue(await driver.find('.test-custom-widget-gallery-container').isDisplayed());
assert.deepEqual(await options(), [CUSTOM_URL, widget1.name, widget2.name]); await gu.sendKeys(Key.ESCAPE, Key.ESCAPE);
await toggle(); assert.isFalse(await driver.find('.test-custom-widget-gallery-container').isPresent());
}); });
it('should switch between widgets', async () => { it('should switch between widgets', async () => {
// Test custom URL. // Test Custom URL.
await toggle(); assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);
await select(CUSTOM_URL); assert.isTrue((await content()).startsWith('Custom widget'));
assert.equal(await current(), CUSTOM_URL); await gu.setCustomWidgetUrl(`${widgetServerUrl}/200`);
assert.equal(await getUrl(), ''); assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);
await setUrl('/200');
assert.equal(await content(), 'OK'); assert.equal(await content(), 'OK');
// Test first widget. // Test first widget.
await toggle(); await gu.setCustomWidget(widget1.name);
await select(widget1.name); assert.equal(await gu.getCustomWidgetName(), widget1.name);
assert.equal(await current(), widget1.name); assert.equal(await gu.getCustomWidgetInfo('description'), widget1.description);
assert.equal(await gu.getCustomWidgetInfo('developer'), widget1.authors?.[0].name);
assert.equal(await gu.getCustomWidgetInfo('last-updated'), 'July 30, 2024');
assert.equal(await content(), widget1.name); assert.equal(await content(), widget1.name);
// Test second widget. // Test second widget.
await toggle(); await gu.setCustomWidget(widget2.name);
await select(widget2.name); assert.equal(await gu.getCustomWidgetName(), widget2.name);
assert.equal(await current(), widget2.name); assert.equal(await gu.getCustomWidgetInfo('description'), '');
assert.equal(await gu.getCustomWidgetInfo('developer'), '');
assert.equal(await gu.getCustomWidgetInfo('last-updated'), '');
assert.equal(await content(), widget2.name); assert.equal(await content(), widget2.name);
// Go back to Custom URL. // Go back to Custom URL.
await toggle(); await gu.setCustomWidget(CUSTOM_URL);
await select(CUSTOM_URL); assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);
assert.equal(await getUrl(), ''); assert.isTrue((await content()).startsWith('Custom widget'));
assert.equal(await current(), CUSTOM_URL); await gu.setCustomWidgetUrl(`${widgetServerUrl}/200`);
await setUrl('/200'); assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);
assert.equal(await content(), 'OK'); assert.equal(await content(), 'OK');
// Clear url and test if message page is shown. // Clear url and test if message page is shown.
await setUrl(''); await gu.setCustomWidgetUrl('');
assert.equal(await current(), CUSTOM_URL); assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);
assert.isTrue((await content()).startsWith('Custom widget')); // start page assert.isTrue((await content()).startsWith('Custom widget'));
await recreatePanel(); await recreatePanel();
assert.equal(await current(), CUSTOM_URL); assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);
await gu.undo(7); await gu.undo(6);
}); });
it('should support theme variables', async () => { it('should support theme variables', async () => {
widgets = [widgetWithTheme]; widgets = [widgetWithTheme];
await useManifest(manifestEndpoint); await reloadWidgets();
await recreatePanel(); await recreatePanel();
await toggle(); await gu.setCustomWidget(widgetWithTheme.name);
await select(widgetWithTheme.name); assert.equal(await gu.getCustomWidgetName(), widgetWithTheme.name);
assert.equal(await current(), widgetWithTheme.name);
assert.equal(await content(), widgetWithTheme.name); assert.equal(await content(), widgetWithTheme.name);
const getWidgetColor = async () => { const getWidgetColor = async () => {
@ -316,18 +327,14 @@ describe('CustomWidgets', function () {
// Check that the widget is back to using the GristLight text color. // Check that the widget is back to using the GristLight text color.
assert.equal(await getWidgetColor(), 'rgba(38, 38, 51, 1)'); assert.equal(await getWidgetColor(), 'rgba(38, 38, 51, 1)');
// Re-enable widget repository.
await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
}); });
it("should support widgets that don't use the plugin api", async () => { it("should support widgets that don't use the plugin api", async () => {
widgets = [widgetNoPluginApi]; widgets = [widgetNoPluginApi];
await useManifest(manifestEndpoint); await reloadWidgets();
await recreatePanel(); await recreatePanel();
await toggle(); await gu.setCustomWidget(widgetNoPluginApi.name);
await select(widgetNoPluginApi.name); assert.equal(await gu.getCustomWidgetName(), widgetNoPluginApi.name);
assert.equal(await current(), widgetNoPluginApi.name);
// Check that the widget loaded and its iframe is visible. // Check that the widget loaded and its iframe is visible.
assert.equal(await content(), widgetNoPluginApi.name); assert.equal(await content(), widgetNoPluginApi.name);
@ -335,7 +342,7 @@ describe('CustomWidgets', function () {
// Revert to original configuration. // Revert to original configuration.
widgets = [widget1, widget2]; widgets = [widget1, widget2];
await useManifest(manifestEndpoint); await reloadWidgets();
await recreatePanel(); await recreatePanel();
}); });
@ -343,13 +350,15 @@ describe('CustomWidgets', function () {
const testError = async (url: string, error: string) => { const testError = async (url: string, error: string) => {
// Switch section to rebuild the creator panel. // Switch section to rebuild the creator panel.
await useManifest(url); await useManifest(url);
await reloadWidgets();
await recreatePanel(); await recreatePanel();
assert.include(await getErrorMessage(), error); assert.include(await getErrorMessage(), error);
await gu.wipeToasts(); await gu.wipeToasts();
// List should contain only a Custom URL. // Gallery should only contain the Custom URL widget.
await toggle(); await gu.openCustomWidgetGallery();
assert.deepEqual(await options(), [CUSTOM_URL]); assert.deepEqual(await galleryWidgets(), [CUSTOM_URL]);
await toggle(); await gu.wipeToasts();
await gu.sendKeys(Key.ESCAPE);
}; };
await testError('/404', "Remote widget list not found"); await testError('/404', "Remote widget list not found");
@ -361,6 +370,7 @@ describe('CustomWidgets', function () {
// Reset to valid manifest. // Reset to valid manifest.
await useManifest(manifestEndpoint); await useManifest(manifestEndpoint);
await reloadWidgets();
await recreatePanel(); await recreatePanel();
}); });
@ -371,15 +381,14 @@ describe('CustomWidgets', function () {
*/ */
it.skip('should show widget when it was removed from list', async () => { it.skip('should show widget when it was removed from list', async () => {
// Select widget1 and then remove it from the list. // Select widget1 and then remove it from the list.
await toggle(); await gu.setCustomWidget(widget1.name);
await select(widget1.name);
widgets = [widget2]; widgets = [widget2];
// Invalidate cache. // Invalidate cache.
await useManifest(manifestEndpoint); await reloadWidgets();
// Toggle sections to reset creator panel and fetch list of available widgets. // Toggle sections to reset creator panel and fetch list of available widgets.
await recreatePanel(); await recreatePanel();
// But still should be selected with a correct url. // But still should be selected with a correct url.
assert.equal(await current(), widget1.name); assert.equal(await gu.getCustomWidgetName(), widget1.name);
assert.equal(await content(), widget1.name); assert.equal(await content(), widget1.name);
await gu.undo(1); await gu.undo(1);
}); });
@ -387,26 +396,22 @@ describe('CustomWidgets', function () {
it('should switch access level to none on new widget', async () => { it('should switch access level to none on new widget', async () => {
widgets = [widget1, widget2]; widgets = [widget1, widget2];
await recreatePanel(); await recreatePanel();
await toggle(); await gu.setCustomWidget(widget1.name);
await select(widget1.name);
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
await access(AccessLevel.full); await access(AccessLevel.full);
assert.equal(await access(), AccessLevel.full); assert.equal(await access(), AccessLevel.full);
await toggle(); await gu.setCustomWidget(widget2.name);
await select(widget2.name);
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
await access(AccessLevel.full); await access(AccessLevel.full);
assert.equal(await access(), AccessLevel.full); assert.equal(await access(), AccessLevel.full);
await toggle(); await gu.setCustomWidget(CUSTOM_URL);
await select(CUSTOM_URL);
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
await access(AccessLevel.full); await access(AccessLevel.full);
assert.equal(await access(), AccessLevel.full); assert.equal(await access(), AccessLevel.full);
await toggle(); await gu.setCustomWidget(widget2.name);
await select(widget2.name);
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
await access(AccessLevel.full); await access(AccessLevel.full);
assert.equal(await access(), AccessLevel.full); assert.equal(await access(), AccessLevel.full);
@ -416,19 +421,18 @@ describe('CustomWidgets', function () {
it('should prompt for access change', async () => { it('should prompt for access change', async () => {
widgets = [widget1, widget2, widgetFull, widgetNone, widgetRead]; widgets = [widget1, widget2, widgetFull, widgetNone, widgetRead];
await useManifest(manifestEndpoint); await reloadWidgets();
await recreatePanel(); await recreatePanel();
const test = async (w: ICustomWidget) => { const test = async (w: ICustomWidget) => {
// Select widget without desired access level // Select widget without desired access level
await toggle(); await gu.setCustomWidget(widget1.name);
await select(widget1.name);
assert.isFalse(await hasPrompt()); assert.isFalse(await hasPrompt());
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
// Select one with desired access level // Select one with desired access level
await toggle(); await gu.setCustomWidget(w.name);
await select(w.name);
// Access level should be still none (test by content which will display access level from query string) // Access level should be still none (test by content which will display access level from query string)
assert.equal(await content(), AccessLevel.none); assert.equal(await content(), AccessLevel.none);
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
@ -440,13 +444,11 @@ describe('CustomWidgets', function () {
assert.equal(await access(), w.accessLevel); assert.equal(await access(), w.accessLevel);
// Do the same, but this time reject // Do the same, but this time reject
await toggle(); await gu.setCustomWidget(widget1.name);
await select(widget1.name);
assert.isFalse(await hasPrompt()); assert.isFalse(await hasPrompt());
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
await toggle(); await gu.setCustomWidget(w.name);
await select(w.name);
assert.isTrue(await hasPrompt()); assert.isTrue(await hasPrompt());
assert.equal(await content(), AccessLevel.none); assert.equal(await content(), AccessLevel.none);
@ -462,14 +464,12 @@ describe('CustomWidgets', function () {
it('should auto accept none access level', async () => { it('should auto accept none access level', async () => {
// Select widget without access level // Select widget without access level
await toggle(); await gu.setCustomWidget(widget1.name);
await select(widget1.name);
assert.isFalse(await hasPrompt()); assert.isFalse(await hasPrompt());
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
// Switch to one with none access level // Switch to one with none access level
await toggle(); await gu.setCustomWidget(widgetNone.name);
await select(widgetNone.name);
assert.isFalse(await hasPrompt()); assert.isFalse(await hasPrompt());
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
assert.equal(await content(), AccessLevel.none); assert.equal(await content(), AccessLevel.none);
@ -477,14 +477,12 @@ describe('CustomWidgets', function () {
it('should show prompt when user switches sections', async () => { it('should show prompt when user switches sections', async () => {
// Select widget without access level // Select widget without access level
await toggle(); await gu.setCustomWidget(widget1.name);
await select(widget1.name);
assert.isFalse(await hasPrompt()); assert.isFalse(await hasPrompt());
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
// Switch to one with full access level // Switch to one with full access level
await toggle(); await gu.setCustomWidget(widgetFull.name);
await select(widgetFull.name);
assert.isTrue(await hasPrompt()); assert.isTrue(await hasPrompt());
// Switch section, and test if prompt is hidden // Switch section, and test if prompt is hidden
@ -496,19 +494,16 @@ describe('CustomWidgets', function () {
it('should hide prompt when user switches widget', async () => { it('should hide prompt when user switches widget', async () => {
// Select widget without access level // Select widget without access level
await toggle(); await gu.setCustomWidget(widget1.name);
await select(widget1.name);
assert.isFalse(await hasPrompt()); assert.isFalse(await hasPrompt());
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
// Switch to one with full access level // Switch to one with full access level
await toggle(); await gu.setCustomWidget(widgetFull.name);
await select(widgetFull.name);
assert.isTrue(await hasPrompt()); assert.isTrue(await hasPrompt());
// Switch to another level. // Switch to another level.
await toggle(); await gu.setCustomWidget(widget1.name);
await select(widget1.name);
assert.isFalse(await hasPrompt()); assert.isFalse(await hasPrompt());
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
}); });
@ -516,8 +511,7 @@ describe('CustomWidgets', function () {
it('should hide prompt when manually changes access level', async () => { it('should hide prompt when manually changes access level', async () => {
// Select widget with no access level // Select widget with no access level
const selectNone = async () => { const selectNone = async () => {
await toggle(); await gu.setCustomWidget(widgetNone.name);
await select(widgetNone.name);
assert.isFalse(await hasPrompt()); assert.isFalse(await hasPrompt());
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
assert.equal(await content(), AccessLevel.none); assert.equal(await content(), AccessLevel.none);
@ -525,8 +519,7 @@ describe('CustomWidgets', function () {
// Selects widget with full access level // Selects widget with full access level
const selectFull = async () => { const selectFull = async () => {
await toggle(); await gu.setCustomWidget(widgetFull.name);
await select(widgetFull.name);
assert.isTrue(await hasPrompt()); assert.isTrue(await hasPrompt());
assert.equal(await content(), AccessLevel.none); assert.equal(await content(), AccessLevel.none);
assert.equal(await content(), AccessLevel.none); assert.equal(await content(), AccessLevel.none);
@ -559,26 +552,140 @@ describe('CustomWidgets', function () {
assert.equal(await access(), AccessLevel.none); assert.equal(await access(), AccessLevel.none);
assert.equal(await content(), AccessLevel.none); assert.equal(await content(), AccessLevel.none);
}); });
});
describe('gallery', () => {
afterEach(() => gu.checkForErrors());
it('should show available widgets', async () => {
await gu.openCustomWidgetGallery();
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
['Custom URL', 'full', 'none', 'read table', 'W1', 'W2']
);
});
it('should show available metadata', async () => {
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget', (el) =>
el.matches('.test-custom-widget-gallery-widget-custom')),
[true, false, false, false, false, false]
);
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget', (el) =>
el.matches('.test-custom-widget-gallery-widget-grist')),
[false, true, true, true, true, false]
);
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget', (el) =>
el.matches('.test-custom-widget-gallery-widget-community')),
[false, false, false, false, false, true]
);
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget-description', (el) => el.getText()),
[
'Add a widget from outside this gallery.',
'(Missing info)',
'(Missing info)',
'(Missing info)',
'Widget 1 description',
'(Missing info)',
]
);
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget-developer', (el) => el.getText()),
[
'(Missing info)',
'(Missing info)',
]
);
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget-last-updated', (el) => el.getText()),
[
'(Missing info)',
'(Missing info)',
'(Missing info)',
'July 30, 2024',
'(Missing info)',
]
);
});
it('should filter widgets on search', async () => {
await driver.find('.test-custom-widget-gallery-search').click();
await gu.sendKeys('Custom');
await gu.waitToPass(async () => {
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
['Custom URL']
);
}, 200);
await gu.sendKeys(await gu.selectAllKey(), Key.DELETE);
await gu.waitToPass(async () => {
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
['Custom URL', 'full', 'none', 'read table', 'W1', 'W2']
);
}, 200);
await gu.sendKeys('W');
await gu.waitToPass(async () => {
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
['Custom URL', 'W1', 'W2']
);
}, 200);
await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'tab');
await gu.waitToPass(async () => {
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
['read table']
);
}, 200);
await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'Markdown');
await gu.waitToPass(async () => {
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
[]
);
}, 200);
await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'Developer 1');
await gu.waitToPass(async () => {
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
['W1']
);
}, 200);
});
it('should offer only custom url when disabled', async () => { it('should only show Custom URL widget when repository is disabled', async () => {
await toggle(); await gu.sendKeys(Key.ESCAPE);
await select(CUSTOM_URL);
await driver.executeScript('window.gristConfig.enableWidgetRepository = false;'); await driver.executeScript('window.gristConfig.enableWidgetRepository = false;');
await recreatePanel(); await driver.executeAsyncScript(
assert.isTrue(await driver.find('.test-config-widget-url').isDisplayed()); (done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done()
assert.isFalse(await driver.find('.test-config-widget-select').isPresent()); );
await gu.openCustomWidgetGallery();
assert.deepEqual(
await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()),
['Custom URL']
);
await gu.sendKeys(Key.ESCAPE);
await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
await driver.executeAsyncScript(
(done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done()
);
}); });
}); });
describe('gristApiSupport', async ()=>{ describe('gristApiSupport', async ()=>{
beforeEach(async function () { beforeEach(async function () {
// Override gristConfig to enable widget list.
await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
// We need to be sure that widget configuration panel is open all the time. // We need to be sure that widget configuration panel is open all the time.
await gu.toggleSidePanel('right', 'open'); await gu.toggleSidePanel('right', 'open');
await recreatePanel(); await recreatePanel();
await driver.findWait('.test-right-tab-pagewidget', 100).click(); await driver.findWait('.test-right-tab-pagewidget', 100).click();
}); });
afterEach(() => gu.checkForErrors());
it('should set language in widget url', async () => { it('should set language in widget url', async () => {
function languageMenu() { function languageMenu() {
return gu.currentDriver().find('.test-account-page-language .test-select-open'); return gu.currentDriver().find('.test-account-page-language .test-select-open');
@ -602,10 +709,9 @@ describe('CustomWidgets', function () {
} }
widgets = [widget1]; widgets = [widget1];
await useManifest(manifestEndpoint); await reloadWidgets();
await gu.openWidgetPanel(); await gu.openWidgetPanel();
await toggle(); await gu.setCustomWidget(widget1.name);
await select(widget1.name);
//Switch language to Polish //Switch language to Polish
await switchLanguage('Polski'); await switchLanguage('Polski');
//Check if widgets have "pl" in url //Check if widgets have "pl" in url
@ -621,8 +727,6 @@ describe('CustomWidgets', function () {
await gu.toggleSidePanel('right', 'open'); await gu.toggleSidePanel('right', 'open');
await driver.find('.test-config-widget').click(); await driver.find('.test-config-widget').click();
await gu.waitForServer(); await gu.waitForServer();
await toggle();
await select(widget1.name);
await access(AccessLevel.full); await access(AccessLevel.full);
// Check an upsert works. // Check an upsert works.
@ -735,6 +839,7 @@ describe('CustomWidgets', function () {
}); });
afterEach(async function() { afterEach(async function() {
await gu.checkForErrors();
oldEnv.restore(); oldEnv.restore();
await server.restart(); await server.restart();
await gu.reloadDoc(); await gu.reloadDoc();
@ -745,10 +850,10 @@ describe('CustomWidgets', function () {
// Double-check that using one external widget, we see // Double-check that using one external widget, we see
// just that widget listed. // just that widget listed.
widgets = [widget1]; widgets = [widget1];
await useManifest(manifestEndpoint); await reloadWidgets();
await enableWidgetsAndShowPanel(); await enableWidgetsAndShowPanel();
await toggle(); await gu.openCustomWidgetGallery();
assert.deepEqual(await options(), [ assert.deepEqual(await galleryWidgets(), [
CUSTOM_URL, widget1.name, CUSTOM_URL, widget1.name,
]); ]);
@ -848,13 +953,13 @@ describe('CustomWidgets', function () {
await gu.reloadDoc(); await gu.reloadDoc();
// Continue using one external widget. // Continue using one external widget.
await useManifest(manifestEndpoint); await reloadWidgets();
await enableWidgetsAndShowPanel(); await enableWidgetsAndShowPanel();
// Check we see one external widget and two bundled ones. // Check we see one external widget and two bundled ones.
await toggle(); await gu.openCustomWidgetGallery();
assert.deepEqual(await options(), [ assert.deepEqual(await galleryWidgets(), [
CUSTOM_URL, widget1.name, 'P1 (My Widgets)', 'P2 (My Widgets)', CUSTOM_URL, 'P1 (My Widgets)', 'P2 (My Widgets)', widget1.name,
]); ]);
// Prepare to check content of widgets. // Prepare to check content of widgets.
@ -867,24 +972,22 @@ describe('CustomWidgets', function () {
} }
// Check built-in P1 works as expected. // Check built-in P1 works as expected.
await select(/P1/); await gu.setCustomWidget(/P1/, {openGallery: false});
assert.equal(await current(), 'P1 (My Widgets)'); assert.equal(await gu.getCustomWidgetName(), 'P1 (My Widgets)');
await gu.waitToPass(async () => { await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'P1'); assert.equal(await getWidgetText(), 'P1');
}); });
// Check external W1 works as expected. // Check external W1 works as expected.
await toggle(); await gu.setCustomWidget(/W1/);
await select(/W1/); assert.equal(await gu.getCustomWidgetName(), 'W1');
assert.equal(await current(), 'W1');
await gu.waitToPass(async () => { await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'W1'); assert.equal(await getWidgetText(), 'W1');
}); });
// Check build-in P2 works as expected. // Check build-in P2 works as expected.
await toggle(); await gu.setCustomWidget(/P2/);
await select(/P2/); assert.equal(await gu.getCustomWidgetName(), 'P2 (My Widgets)');
assert.equal(await current(), 'P2 (My Widgets)');
await gu.waitToPass(async () => { await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'P2'); assert.equal(await getWidgetText(), 'P2');
}); });

@ -1,8 +1,9 @@
import {AccessLevel} from 'app/common/CustomWidget';
import {addToRepl, assert, driver, Key} from 'mocha-webdriver'; import {addToRepl, assert, driver, Key} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils'; import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils'; import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import {addStatic, serveSomething} from 'test/server/customUtil'; import {addStatic, serveSomething} from 'test/server/customUtil';
import {AccessLevel} from 'app/common/CustomWidget'; import {EnvironmentSnapshot} from 'test/server/testUtils';
// Valid manifest url. // Valid manifest url.
const manifestEndpoint = '/manifest.json'; const manifestEndpoint = '/manifest.json';
@ -16,7 +17,7 @@ const READ_WIDGET = 'Read';
const FULL_WIDGET = 'Full'; const FULL_WIDGET = 'Full';
const COLUMN_WIDGET = 'COLUMN_WIDGET'; const COLUMN_WIDGET = 'COLUMN_WIDGET';
const REQUIRED_WIDGET = 'REQUIRED_WIDGET'; const REQUIRED_WIDGET = 'REQUIRED_WIDGET';
// Custom URL label in selectbox. // Custom URL label.
const CUSTOM_URL = 'Custom URL'; const CUSTOM_URL = 'Custom URL';
// Holds url for sample widget server. // Holds url for sample widget server.
let widgetServerUrl = ''; let widgetServerUrl = '';
@ -27,14 +28,9 @@ function createConfigUrl(ready?: any) {
return ready ? `${widgetServerUrl}/config?ready=` + encodeURI(JSON.stringify(ready)) : `${widgetServerUrl}/config`; return ready ? `${widgetServerUrl}/config?ready=` + encodeURI(JSON.stringify(ready)) : `${widgetServerUrl}/config`;
} }
// Open or close widget menu.
const click = (selector: string) => driver.find(`${selector}`).click(); const click = (selector: string) => driver.find(`${selector}`).click();
const toggleDrop = (selector: string) => click(`${selector} .test-select-open`); const toggleDrop = (selector: string) => click(`${selector} .test-select-open`);
const toggleWidgetMenu = () => toggleDrop('.test-config-widget-select');
const getOptions = () => driver.findAll('.test-select-menu li', el => el.getText()); const getOptions = () => driver.findAll('.test-select-menu li', el => el.getText());
// Get current value from widget menu.
const currentWidget = () => driver.find('.test-config-widget-select .test-select-open').getText();
// Select widget from the menu.
const clickOption = async (text: string | RegExp) => { const clickOption = async (text: string | RegExp) => {
await driver.findContent('.test-select-menu li', text).click(); await driver.findContent('.test-select-menu li', text).click();
await gu.waitForServer(); await gu.waitForServer();
@ -58,13 +54,11 @@ async function getListItems(col: string) {
.findAll(`.test-config-widget-map-list-for-${col} .test-config-widget-ref-select-label`, el => el.getText()); .findAll(`.test-config-widget-map-list-for-${col} .test-config-widget-ref-select-label`, el => el.getText());
} }
// When refreshing, we need to make sure widget repository is enabled once again.
async function refresh() { async function refresh() {
await driver.navigate().refresh(); await driver.navigate().refresh();
await gu.waitForDocToLoad(); await gu.waitForDocToLoad();
// Switch section and enable config // Switch section and enable config
await gu.selectSectionByTitle('Table'); await gu.selectSectionByTitle('Table');
await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
await gu.selectSectionByTitle('Widget'); await gu.selectSectionByTitle('Widget');
} }
@ -130,6 +124,7 @@ describe('CustomWidgetsConfig', function () {
let mainSession: gu.Session; let mainSession: gu.Session;
gu.bigScreen(); gu.bigScreen();
let oldEnv: EnvironmentSnapshot;
addToRepl('getOptions', getOptions); addToRepl('getOptions', getOptions);
@ -137,6 +132,12 @@ describe('CustomWidgetsConfig', function () {
if (server.isExternalServer()) { if (server.isExternalServer()) {
this.skip(); this.skip();
} }
oldEnv = new EnvironmentSnapshot();
// Set to an unused URL so that the client reports that widgets are available.
process.env.GRIST_WIDGET_LIST_URL = 'unused';
await server.restart();
// Create simple widget server that serves manifest.json file, some widgets and some error pages. // Create simple widget server that serves manifest.json file, some widgets and some error pages.
const widgetServer = await serveSomething(app => { const widgetServer = await serveSomething(app => {
app.get('/manifest.json', (_, res) => { app.get('/manifest.json', (_, res) => {
@ -188,25 +189,23 @@ describe('CustomWidgetsConfig', function () {
mainSession = await gu.session().login(); mainSession = await gu.session().login();
const doc = await mainSession.tempDoc(cleanup, 'CustomWidget.grist'); const doc = await mainSession.tempDoc(cleanup, 'CustomWidget.grist');
docId = doc.id; docId = doc.id;
// Make sure widgets are enabled.
await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
await gu.toggleSidePanel('right', 'open'); await gu.toggleSidePanel('right', 'open');
await gu.selectSectionByTitle('Widget'); await gu.selectSectionByTitle('Widget');
}); });
after(async function() { after(async function() {
await server.testingHooks.setWidgetRepositoryUrl(''); await server.testingHooks.setWidgetRepositoryUrl('');
oldEnv.restore();
await server.restart();
}); });
beforeEach(async () => { beforeEach(async () => {
// Before each test, we will switch to Custom Url (to cleanup the widget) // Before each test, we will switch to Custom Url (to cleanup the widget)
// and then back to the Tester widget. // and then back to the Tester widget.
if ((await currentWidget()) !== CUSTOM_URL) { if ((await gu.getCustomWidgetName()) !== CUSTOM_URL) {
await toggleWidgetMenu(); await gu.setCustomWidget(CUSTOM_URL);
await clickOption(CUSTOM_URL);
} }
await toggleWidgetMenu(); await gu.setCustomWidget(TESTER_WIDGET);
await clickOption(TESTER_WIDGET);
await widget.waitForFrame(); await widget.waitForFrame();
}); });
@ -218,8 +217,7 @@ describe('CustomWidgetsConfig', function () {
assert.isFalse(await driver.find('.test-custom-widget-ready').isPresent()); assert.isFalse(await driver.find('.test-custom-widget-ready').isPresent());
// Now select the widget that requires a column. // Now select the widget that requires a column.
await toggleWidgetMenu(); await gu.setCustomWidget(REQUIRED_WIDGET);
await clickOption(REQUIRED_WIDGET);
await gu.acceptAccessRequest(); await gu.acceptAccessRequest();
// The widget iframe should be covered with a text explaining that the widget is not configured. // The widget iframe should be covered with a text explaining that the widget is not configured.
@ -251,15 +249,11 @@ describe('CustomWidgetsConfig', function () {
}); });
it('should hide mappings when there is no good column', async () => { it('should hide mappings when there is no good column', async () => {
if ((await currentWidget()) !== CUSTOM_URL) { await gu.setCustomWidgetUrl(
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
}
await gu.setWidgetUrl(
createConfigUrl({ createConfigUrl({
columns: [{name: 'M2', type: 'Date', optional: true}], columns: [{name: 'M2', type: 'Date', optional: true}],
requiredAccess: 'read table', requiredAccess: 'read table',
}) }),
); );
await widget.waitForFrame(); await widget.waitForFrame();
@ -307,11 +301,7 @@ describe('CustomWidgetsConfig', function () {
it('should clear optional mapping', async () => { it('should clear optional mapping', async () => {
const revert = await gu.begin(); const revert = await gu.begin();
if ((await currentWidget()) !== CUSTOM_URL) { await gu.setCustomWidgetUrl(
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
}
await gu.setWidgetUrl(
createConfigUrl({ createConfigUrl({
columns: [{name: 'M2', type: 'Date', optional: true}], columns: [{name: 'M2', type: 'Date', optional: true}],
requiredAccess: 'read table', requiredAccess: 'read table',
@ -356,9 +346,7 @@ describe('CustomWidgetsConfig', function () {
it('should render columns mapping', async () => { it('should render columns mapping', async () => {
const revert = await gu.begin(); const revert = await gu.begin();
assert.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent()); assert.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
await toggleWidgetMenu(); await gu.setCustomWidget(COLUMN_WIDGET);
// Select widget that has single column configuration.
await clickOption(COLUMN_WIDGET);
await widget.waitForFrame(); await widget.waitForFrame();
await gu.acceptAccessRequest(); await gu.acceptAccessRequest();
await widget.waitForPendingRequests(); await widget.waitForPendingRequests();
@ -386,11 +374,9 @@ describe('CustomWidgetsConfig', function () {
it('should render multiple mappings', async () => { it('should render multiple mappings', async () => {
const revert = await gu.begin(); const revert = await gu.begin();
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
// This is not standard way of creating widgets. The widgets in this test is reading this parameter // This is not standard way of creating widgets. The widgets in this test is reading this parameter
// and is using it to invoke the ready method. // and is using it to invoke the ready method.
await gu.setWidgetUrl( await gu.setCustomWidgetUrl(
createConfigUrl({ createConfigUrl({
columns: ['M1', {name: 'M2', optional: true}, {name: 'M3', title: 'T3'}, {name: 'M4', type: 'Text'}], columns: ['M1', {name: 'M2', optional: true}, {name: 'M3', title: 'T3'}, {name: 'M4', type: 'Text'}],
requiredAccess: 'read table', requiredAccess: 'read table',
@ -448,8 +434,7 @@ describe('CustomWidgetsConfig', function () {
it('should clear mappings on widget switch', async () => { it('should clear mappings on widget switch', async () => {
const revert = await gu.begin(); const revert = await gu.begin();
await toggleWidgetMenu(); await gu.setCustomWidget(COLUMN_WIDGET);
await clickOption(COLUMN_WIDGET);
await widget.waitForFrame(); await widget.waitForFrame();
await gu.acceptAccessRequest(); await gu.acceptAccessRequest();
await widget.waitForPendingRequests(); await widget.waitForPendingRequests();
@ -466,8 +451,7 @@ describe('CustomWidgetsConfig', function () {
await clickOption('A'); await clickOption('A');
// Now change to a widget without columns // Now change to a widget without columns
await toggleWidgetMenu(); await gu.setCustomWidget(NORMAL_WIDGET);
await clickOption(NORMAL_WIDGET);
// Picker should disappear and column mappings should be visible // Picker should disappear and column mappings should be visible
assert.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent()); assert.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
@ -481,8 +465,7 @@ describe('CustomWidgetsConfig', function () {
{id: 3, A: 'C'}, {id: 3, A: 'C'},
]); ]);
// Now go back to the widget with mappings. // Now go back to the widget with mappings.
await toggleWidgetMenu(); await gu.setCustomWidget(COLUMN_WIDGET);
await clickOption(COLUMN_WIDGET);
await widget.waitForFrame(); await widget.waitForFrame();
await gu.acceptAccessRequest(); await gu.acceptAccessRequest();
await widget.waitForPendingRequests(); await widget.waitForPendingRequests();
@ -494,9 +477,7 @@ describe('CustomWidgetsConfig', function () {
it('should render multiple options', async () => { it('should render multiple options', async () => {
const revert = await gu.begin(); const revert = await gu.begin();
await toggleWidgetMenu(); await gu.setCustomWidgetUrl(
await clickOption(CUSTOM_URL);
await gu.setWidgetUrl(
createConfigUrl({ createConfigUrl({
columns: [ columns: [
{name: 'M1', allowMultiple: true, optional: true}, {name: 'M1', allowMultiple: true, optional: true},
@ -578,9 +559,7 @@ describe('CustomWidgetsConfig', function () {
it('should support multiple types in mappings', async () => { it('should support multiple types in mappings', async () => {
const revert = await gu.begin(); const revert = await gu.begin();
await toggleWidgetMenu(); await gu.setCustomWidgetUrl(
await clickOption(CUSTOM_URL);
await gu.setWidgetUrl(
createConfigUrl({ createConfigUrl({
columns: [ columns: [
{name: 'M1', type: 'Date,DateTime', optional: true}, {name: 'M1', type: 'Date,DateTime', optional: true},
@ -639,9 +618,7 @@ describe('CustomWidgetsConfig', function () {
it('should support strictType setting', async () => { it('should support strictType setting', async () => {
const revert = await gu.begin(); const revert = await gu.begin();
await toggleWidgetMenu(); await gu.setCustomWidgetUrl(
await clickOption(CUSTOM_URL);
await gu.setWidgetUrl(
createConfigUrl({ createConfigUrl({
columns: [ columns: [
{name: 'Any', type: 'Any', strictType: true, optional: true}, {name: 'Any', type: 'Any', strictType: true, optional: true},
@ -683,9 +660,7 @@ describe('CustomWidgetsConfig', function () {
it('should react to widget options change', async () => { it('should react to widget options change', async () => {
const revert = await gu.begin(); const revert = await gu.begin();
await toggleWidgetMenu(); await gu.setCustomWidgetUrl(
await clickOption(CUSTOM_URL);
await gu.setWidgetUrl(
createConfigUrl({ createConfigUrl({
columns: [ columns: [
{name: 'Choice', type: 'Choice', strictType: true, optional: true}, {name: 'Choice', type: 'Choice', strictType: true, optional: true},
@ -731,10 +706,8 @@ describe('CustomWidgetsConfig', function () {
it('should remove mapping when column is deleted', async () => { it('should remove mapping when column is deleted', async () => {
const revert = await gu.begin(); const revert = await gu.begin();
await toggleWidgetMenu();
// Prepare mappings for single and multiple columns // Prepare mappings for single and multiple columns
await clickOption(CUSTOM_URL); await gu.setCustomWidgetUrl(
await gu.setWidgetUrl(
createConfigUrl({ createConfigUrl({
columns: [{name: 'M1', optional: true}, {name: 'M2', allowMultiple: true, optional: true}], columns: [{name: 'M1', optional: true}, {name: 'M2', allowMultiple: true, optional: true}],
requiredAccess: 'read table', requiredAccess: 'read table',
@ -820,10 +793,8 @@ describe('CustomWidgetsConfig', function () {
it('should remove mapping when column type is changed', async () => { it('should remove mapping when column type is changed', async () => {
const revert = await gu.begin(); const revert = await gu.begin();
await toggleWidgetMenu();
// Prepare mappings for single and multiple columns // Prepare mappings for single and multiple columns
await clickOption(CUSTOM_URL); await gu.setCustomWidgetUrl(
await gu.setWidgetUrl(
createConfigUrl({ createConfigUrl({
columns: [ columns: [
{name: 'M1', type: 'Text', optional: true}, {name: 'M1', type: 'Text', optional: true},
@ -900,10 +871,9 @@ describe('CustomWidgetsConfig', function () {
await gu.undo(); await gu.undo();
// Add Custom - no section option by default // Add Custom - no section option by default
await gu.addNewSection(/Custom/, /Table1/); await gu.addNewSection(/Custom/, /Table1/, {customWidget: /Custom URL/});
assert.isFalse(await hasSectionOption()); assert.isFalse(await hasSectionOption());
await toggleWidgetMenu(); await gu.setCustomWidget(TESTER_WIDGET);
await clickOption(TESTER_WIDGET);
assert.isTrue(await hasSectionOption()); assert.isTrue(await hasSectionOption());
await gu.undo(2); await gu.undo(2);
}); });
@ -1058,30 +1028,19 @@ describe('CustomWidgetsConfig', function () {
it('should show options action button', async () => { it('should show options action button', async () => {
// Select widget without options // Select widget without options
await toggleWidgetMenu(); await gu.setCustomWidget(NORMAL_WIDGET);
await clickOption(NORMAL_WIDGET);
assert.isFalse(await hasSectionOption()); assert.isFalse(await hasSectionOption());
// Select widget with options // Select widget with options
await toggleWidgetMenu(); await gu.setCustomWidget(TESTER_WIDGET);
await clickOption(TESTER_WIDGET);
assert.isTrue(await hasSectionOption()); assert.isTrue(await hasSectionOption());
// Select widget without options // Select widget without options
await toggleWidgetMenu(); await gu.setCustomWidget(NORMAL_WIDGET);
await clickOption(NORMAL_WIDGET);
assert.isFalse(await hasSectionOption()); assert.isFalse(await hasSectionOption());
}); });
it('should prompt user for correct access level', async () => { it('should prompt user for correct access level', async () => {
// Select widget without request
await toggleWidgetMenu();
await clickOption(NORMAL_WIDGET);
await widget.waitForFrame();
assert.isFalse(await gu.hasAccessPrompt());
assert.equal(await gu.widgetAccess(), AccessLevel.none);
assert.equal(await widget.access(), AccessLevel.none);
// Select widget that requests read access. // Select widget that requests read access.
await toggleWidgetMenu(); await gu.setCustomWidget(READ_WIDGET);
await clickOption(READ_WIDGET);
await widget.waitForFrame(); await widget.waitForFrame();
assert.isTrue(await gu.hasAccessPrompt()); assert.isTrue(await gu.hasAccessPrompt());
assert.equal(await gu.widgetAccess(), AccessLevel.none); assert.equal(await gu.widgetAccess(), AccessLevel.none);
@ -1091,8 +1050,7 @@ describe('CustomWidgetsConfig', function () {
assert.equal(await gu.widgetAccess(), AccessLevel.read_table); assert.equal(await gu.widgetAccess(), AccessLevel.read_table);
assert.equal(await widget.access(), AccessLevel.read_table); assert.equal(await widget.access(), AccessLevel.read_table);
// Select widget that requests full access. // Select widget that requests full access.
await toggleWidgetMenu(); await gu.setCustomWidget(FULL_WIDGET);
await clickOption(FULL_WIDGET);
await widget.waitForFrame(); await widget.waitForFrame();
assert.isTrue(await gu.hasAccessPrompt()); assert.isTrue(await gu.hasAccessPrompt());
assert.equal(await gu.widgetAccess(), AccessLevel.none); assert.equal(await gu.widgetAccess(), AccessLevel.none);
@ -1101,7 +1059,7 @@ describe('CustomWidgetsConfig', function () {
await widget.waitForPendingRequests(); await widget.waitForPendingRequests();
assert.equal(await gu.widgetAccess(), AccessLevel.full); assert.equal(await gu.widgetAccess(), AccessLevel.full);
assert.equal(await widget.access(), AccessLevel.full); assert.equal(await widget.access(), AccessLevel.full);
await gu.undo(5); await gu.undo(4);
}); });
it('should pass readonly mode to custom widget', async () => { it('should pass readonly mode to custom widget', async () => {
@ -1265,7 +1223,6 @@ const widget = {
* any existing widget state (even if the Custom URL was already selected). * any existing widget state (even if the Custom URL was already selected).
*/ */
async resetWidget() { async resetWidget() {
await toggleWidgetMenu(); await gu.setCustomWidget(CUSTOM_URL);
await clickOption(CUSTOM_URL);
} }
}; };

@ -219,16 +219,10 @@ describe('LinkingBidirectional', function() {
}); });
it('should support custom filters', async function() { it('should support custom filters', async function() {
// Add a new custom section with a widget. // Add a new page with a table and custom widget.
await gu.addNewPage('Table', 'Classes', {}); await gu.addNewPage('Table', 'Classes', {});
// Rename this section as Data.
await gu.renameActiveSection('Data'); await gu.renameActiveSection('Data');
await gu.addNewSection('Custom', 'Classes', {customWidget: /Custom URL/, selectBy: 'Data'});
// Add new custom section with a widget.
await gu.addNewSection('Custom', 'Classes', { selectBy: 'Data' });
// Rename this section as Custom.
await gu.renameActiveSection('Custom'); await gu.renameActiveSection('Custom');
// Make sure it can be used as a filter. // Make sure it can be used as a filter.

@ -33,22 +33,17 @@ describe('RightPanel', function() {
await gu.undo(); await gu.undo();
// Add a custom section. // Add a custom section.
await gu.addNewSection('Custom', 'Table1'); await gu.addNewSection('Custom', 'Table1', { customWidget: /Custom URL/ });
assert.isFalse(await gu.isSidePanelOpen('right')); assert.isFalse(await gu.isSidePanelOpen('right'));
await gu.undo(); await gu.undo();
// Add a custom page. // Add a custom page.
await gu.addNewPage('Custom', 'Table1'); await gu.addNewPage('Custom', 'Table1', { customWidget: /Custom URL/ });
assert.isFalse(await gu.isSidePanelOpen('right')); assert.isFalse(await gu.isSidePanelOpen('right'));
await gu.undo(); await gu.undo();
// Now open the panel on the column tab. // Now open the panel on the column tab.
const columnTab = async () => { await gu.openColumnPanel();
await gu.toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click();
};
await columnTab();
// Add a chart section. // Add a chart section.
await gu.addNewSection('Chart', 'Table1'); await gu.addNewSection('Chart', 'Table1');
@ -56,7 +51,7 @@ describe('RightPanel', function() {
assert.isTrue(await driver.find('.test-right-widget-title').isDisplayed()); assert.isTrue(await driver.find('.test-right-widget-title').isDisplayed());
await gu.undo(); await gu.undo();
await columnTab(); await gu.openColumnPanel();
// Add a chart page. // Add a chart page.
await gu.addNewPage('Chart', 'Table1'); await gu.addNewPage('Chart', 'Table1');
@ -64,18 +59,18 @@ describe('RightPanel', function() {
assert.isTrue(await driver.find('.test-right-widget-title').isDisplayed()); assert.isTrue(await driver.find('.test-right-widget-title').isDisplayed());
await gu.undo(); await gu.undo();
await columnTab(); await gu.openColumnPanel();
// Add a custom section. // Add a custom section.
await gu.addNewSection('Custom', 'Table1'); await gu.addNewSection('Custom', 'Table1', { customWidget: /Custom URL/ });
assert.isTrue(await gu.isSidePanelOpen('right')); assert.isTrue(await gu.isSidePanelOpen('right'));
assert.isTrue(await driver.find('.test-right-widget-title').isDisplayed()); assert.isTrue(await driver.find('.test-right-widget-title').isDisplayed());
await gu.undo(); await gu.undo();
await columnTab(); await gu.openColumnPanel();
// Add a custom page. // Add a custom page.
await gu.addNewPage('Custom', 'Table1'); await gu.addNewPage('Custom', 'Table1', { customWidget: /Custom URL/ });
assert.isTrue(await gu.isSidePanelOpen('right')); assert.isTrue(await gu.isSidePanelOpen('right'));
assert.isTrue(await driver.find('.test-right-widget-title').isDisplayed()); assert.isTrue(await driver.find('.test-right-widget-title').isDisplayed());
await gu.undo(); await gu.undo();

@ -100,7 +100,7 @@ describe("SelectBy", function() {
// Create a page with with charts and custom widget and then check that no linking is offered // Create a page with with charts and custom widget and then check that no linking is offered
await gu.addNewPage(/Chart/, /Table1/); await gu.addNewPage(/Chart/, /Table1/);
await gu.addNewSection(/Custom/, /Table2/); await gu.addNewSection(/Custom/, /Table2/, {customWidget: /Custom URL/});
// open add widget to page // open add widget to page
await driver.findWait('.test-dp-add-new', 2000).doClick(); await driver.findWait('.test-dp-add-new', 2000).doClick();

@ -100,15 +100,15 @@ describe("ViewLayoutCollapse", function() {
// Add custom section. // Add custom section.
await gu.addNewPage('Table', 'Companies'); await gu.addNewPage('Table', 'Companies');
await gu.addNewSection('Custom', 'Companies', { selectBy: 'COMPANIES'}); await gu.addNewSection('Custom', 'Companies', {selectBy: 'COMPANIES'});
// Serve custom widget. // Serve custom widget.
const widgetServer = await serveSomething(app => { const widgetServer = await serveSomething(app => {
addStatic(app); addStatic(app);
}); });
cleanup.addAfterAll(widgetServer.shutdown); cleanup.addAfterAll(widgetServer.shutdown);
await gu.setCustomWidgetUrl(widgetServer.url + '/probe/index.html', {openGallery: false});
await gu.openWidgetPanel(); await gu.openWidgetPanel();
await gu.setWidgetUrl(widgetServer.url + '/probe/index.html');
await gu.widgetAccess(AccessLevel.full); await gu.widgetAccess(AccessLevel.full);
// Collapse it. // Collapse it.
@ -139,15 +139,15 @@ describe("ViewLayoutCollapse", function() {
// Add custom section. // Add custom section.
await gu.addNewPage('Table', 'Companies'); await gu.addNewPage('Table', 'Companies');
await gu.addNewSection('Custom', 'Companies', { selectBy: 'COMPANIES'}); await gu.addNewSection('Custom', 'Companies', {selectBy: 'COMPANIES'});
// Serve custom widget. // Serve custom widget.
const widgetServer = await serveSomething(app => { const widgetServer = await serveSomething(app => {
addStatic(app); addStatic(app);
}); });
cleanup.addAfterAll(widgetServer.shutdown); cleanup.addAfterAll(widgetServer.shutdown);
await gu.setCustomWidgetUrl(widgetServer.url + '/probe/index.html', {openGallery: false});
await gu.openWidgetPanel(); await gu.openWidgetPanel();
await gu.setWidgetUrl(widgetServer.url + '/probe/index.html');
await gu.widgetAccess(AccessLevel.full); await gu.widgetAccess(AccessLevel.full);
// Collapse it. // Collapse it.

@ -3242,17 +3242,52 @@ export async function renameActiveTable(name: string) {
await waitForServer(); await waitForServer();
} }
export async function setWidgetUrl(url: string) { export async function getCustomWidgetName() {
await driver.find('.test-config-widget-url').click(); await openWidgetPanel();
// First clear textbox. return await driver.find('.test-config-widget-open-custom-widget-gallery').getText();
await clearInput(); }
if (url) {
await sendKeys(url); export async function getCustomWidgetInfo(info: 'description'|'developer'|'last-updated') {
await openWidgetPanel();
if (await driver.find('.test-config-widget-show-custom-widget-details').isPresent()) {
await driver.find('.test-config-widget-show-custom-widget-details').click();
} }
if (!await driver.find(`.test-config-widget-custom-widget-${info}`).isPresent()) {
return '';
}
return await driver.find(`.test-config-widget-custom-widget-${info}`).getText();
}
export async function openCustomWidgetGallery() {
await openWidgetPanel();
await driver.find('.test-config-widget-open-custom-widget-gallery').click();
await waitForServer();
}
interface SetWidgetOptions {
/** Defaults to `true`. */
openGallery?: boolean;
}
export async function setCustomWidgetUrl(url: string, options: SetWidgetOptions = {}) {
const {openGallery = true} = options;
if (openGallery) { await openCustomWidgetGallery(); }
await driver.find('.test-custom-widget-gallery-custom-url').click();
await clearInput();
if (url) { await sendKeys(url); }
await sendKeys(Key.ENTER); await sendKeys(Key.ENTER);
await waitForServer(); await waitForServer();
} }
export async function setCustomWidget(content: string|RegExp, options: SetWidgetOptions = {}) {
const {openGallery = true} = options;
if (openGallery) { await openCustomWidgetGallery(); }
await driver.findContent('.test-custom-widget-gallery-widget', content).click();
await driver.find('.test-custom-widget-gallery-save').click();
await waitForServer();
}
type BehaviorActions = 'Clear and reset' | 'Convert column to data' | 'Clear and make into formula' | type BehaviorActions = 'Clear and reset' | 'Convert column to data' | 'Clear and make into formula' |
'Convert columns to data'; 'Convert columns to data';
/** /**

@ -99,13 +99,14 @@ export class GristWebDriverUtils {
tableRe: RegExp|string = '', tableRe: RegExp|string = '',
options: PageWidgetPickerOptions = {} options: PageWidgetPickerOptions = {}
) { ) {
const {customWidget, dismissTips, dontAdd, selectBy, summarize, tableName} = options;
const driver = this.driver; const driver = this.driver;
if (options.dismissTips) { await this.dismissBehavioralPrompts(); } if (dismissTips) { await this.dismissBehavioralPrompts(); }
// select right type // select right type
await driver.findContent('.test-wselect-type', typeRe).doClick(); await driver.findContent('.test-wselect-type', typeRe).doClick();
if (options.dismissTips) { await this.dismissBehavioralPrompts(); } if (dismissTips) { await this.dismissBehavioralPrompts(); }
if (tableRe) { if (tableRe) {
const tableEl = driver.findContent('.test-wselect-table', tableRe); const tableEl = driver.findContent('.test-wselect-table', tableRe);
@ -118,34 +119,32 @@ export class GristWebDriverUtils {
// let's select table // let's select table
await tableEl.click(); await tableEl.click();
if (options.dismissTips) { await this.dismissBehavioralPrompts(); } if (dismissTips) { await this.dismissBehavioralPrompts(); }
const pivotEl = tableEl.find('.test-wselect-pivot'); const pivotEl = tableEl.find('.test-wselect-pivot');
if (await pivotEl.isPresent()) { if (await pivotEl.isPresent()) {
await this.toggleSelectable(pivotEl, Boolean(options.summarize)); await this.toggleSelectable(pivotEl, Boolean(summarize));
} }
if (options.summarize) { if (summarize) {
for (const columnEl of await driver.findAll('.test-wselect-column')) { for (const columnEl of await driver.findAll('.test-wselect-column')) {
const label = await columnEl.getText(); const label = await columnEl.getText();
// TODO: Matching cols with regexp calls for trouble and adds no value. I think function should be // TODO: Matching cols with regexp calls for trouble and adds no value. I think function should be
// rewritten using string matching only. // rewritten using string matching only.
const goal = Boolean(options.summarize.find(r => label.match(r))); const goal = Boolean(summarize.find(r => label.match(r)));
await this.toggleSelectable(columnEl, goal); await this.toggleSelectable(columnEl, goal);
} }
} }
if (options.selectBy) { if (selectBy) {
// select link // select link
await driver.find('.test-wselect-selectby').doClick(); await driver.find('.test-wselect-selectby').doClick();
await driver.findContent('.test-wselect-selectby option', options.selectBy).doClick(); await driver.findContent('.test-wselect-selectby option', selectBy).doClick();
} }
} }
if (options.dontAdd) { if (dontAdd) { return; }
return;
}
// add the widget // add the widget
await driver.find('.test-wselect-addBtn').doClick(); await driver.find('.test-wselect-addBtn').doClick();
@ -154,14 +153,20 @@ export class GristWebDriverUtils {
const prompts = await driver.findAll(".test-modal-prompt"); const prompts = await driver.findAll(".test-modal-prompt");
const prompt = prompts[0]; const prompt = prompts[0];
if (prompt) { if (prompt) {
if (options.tableName) { if (tableName) {
await prompt.doClear(); await prompt.doClear();
await prompt.click(); await prompt.click();
await driver.sendKeys(options.tableName); await driver.sendKeys(tableName);
} }
await driver.find(".test-modal-confirm").click(); await driver.find(".test-modal-confirm").click();
} }
if (customWidget) {
await this.waitForServer();
await driver.findContent('.test-custom-widget-gallery-widget-name', customWidget).click();
await driver.find('.test-custom-widget-gallery-save').click();
}
await this.waitForServer(); await this.waitForServer();
} }
@ -269,4 +274,6 @@ export interface PageWidgetPickerOptions {
dontAdd?: boolean; dontAdd?: boolean;
/** If true, dismiss any tooltips that are shown. */ /** If true, dismiss any tooltips that are shown. */
dismissTips?: boolean; dismissTips?: boolean;
/** Optional pattern of custom widget name to select in the gallery. */
customWidget?: RegExp|string;
} }

Loading…
Cancel
Save