(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 {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
import {App} from 'app/client/ui/App';
import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
import {DocHistory} from 'app/client/ui/DocHistory';
import {startDocTour} from "app/client/ui/DocTour";
import {DocTutorial} from 'app/client/ui/DocTutorial';
@ -138,6 +139,13 @@ interface PopupSectionOptions {
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 {
public docModel: DocModel;
public viewModel: ViewRec;
@ -894,38 +902,27 @@ export class GristDoc extends DisposableWithEvents {
/**
* Adds a view section described by val to the current page.
*/
public async addWidgetToPage(val: IPageWidget) {
const docData = this.docModel.docData;
const viewName = this.viewModel.name.peek();
public async addWidgetToPage(widget: IPageWidget) {
const {table, type} = widget;
let tableId: string | null | undefined;
if (val.table === 'New Table') {
if (table === 'New Table') {
tableId = await this._promptForName();
if (tableId === undefined) {
return;
}
}
const widgetType = getTelemetryWidgetTypeFromPageWidget(val);
logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}});
if (val.link !== NoLink) {
logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
if (type === 'custom') {
return showCustomWidgetGallery(this, {
addWidget: () => this._addWidgetToPage(widget, tableId),
});
}
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}),
() => this.addWidgetToPageImpl(val, tableId ?? null)
() => this._addWidgetToPage(widget, tableId ?? null)
);
// 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;
return sectionRef;
}
public async onCreateForm() {
@ -941,80 +938,31 @@ export class GristDoc extends DisposableWithEvents {
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`.
*/
public async addNewPage(val: IPageWidget) {
logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}});
logTelemetryEvent('addedWidget', {
full: {
docIdDigest: this.docId(),
widgetType: getTelemetryWidgetTypeFromPageWidget(val),
},
});
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!);
}
const {table, type} = val;
let tableId: string | null | undefined;
if (table === 'New Table') {
tableId = await this._promptForName();
if (tableId === undefined) { return; }
}
if (type === 'custom') {
return showCustomWidgetGallery(this, {
addWidget: () => this._addPage(val, tableId ?? null) as Promise<{
viewRef: number;
sectionRef: number;
}>,
});
await this.openDocPage(viewRef!);
if (sectionRef) {
// The newly-added section should be given focus.
this.viewModel.activeSectionId(sectionRef);
}
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
if (AttachedCustomWidgets.guard(val.type)) {
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
}
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;
}
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).
*/
@ -1718,7 +1750,7 @@ export class GristDoc extends DisposableWithEvents {
const sectionId = section.id();
// create a new section
const sectionCreationResult = await this.addWidgetToPageImpl(newVal);
const sectionCreationResult = await this._addWidgetToPage(newVal, null, {focus: false, popups: false});
// update section name
const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef);

@ -223,10 +223,15 @@ export class WidgetFrame extends DisposableWithEvents {
// Appends access level to query 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;
}
const urlObj = new URL(url);
urlObj.searchParams.append('access', this._options.access);
urlObj.searchParams.append('readonly', String(this._options.readonly));
// Append user and document preferences to query string.

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

@ -62,8 +62,6 @@ export interface TopAppModel {
orgs: Observable<Organization[]>;
users: Observable<FullUser[]>;
customWidgets: Observable<ICustomWidget[]|null>;
// Reinitialize the app. This is called when org or user changes.
initialize(): void;
@ -162,26 +160,26 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
public readonly orgs = Observable.create<Organization[]>(this, []);
public readonly users = Observable.create<FullUser[]>(this, []);
public readonly plugins: LocalPlugin[] = [];
public readonly customWidgets = Observable.create<ICustomWidget[]|null>(this, null);
private readonly _gristConfig?: GristLoadConfig;
private readonly _gristConfig? = this._window.gristConfig;
// 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
// up new widgets - that seems ok.
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
constructor(window: {gristConfig?: GristLoadConfig},
constructor(private _window: {gristConfig?: GristLoadConfig},
public readonly api: UserAPI = newUserAPIImpl(),
public readonly options: TopAppModelOptions = {}
) {
super();
setErrorNotifier(this.notifier);
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
this._gristConfig = window.gristConfig;
this.isSingleOrg = Boolean(this._gristConfig?.singleOrg);
this.productFlavor = getFlavor(this._gristConfig?.org);
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
const widgets = this.options.useApi === false ? [] : await this.api.getWidgets();
this.customWidgets.set(widgets);
return widgets;
if (this.options.useApi === false || !this._gristConfig?.enableWidgetRepository) {
return [];
}
return await this.api.getWidgets();
});
// 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() {
console.log("testReloadWidgets");
this._widgets.clear();
this.customWidgets.set(null);
console.log("testReloadWidgets cleared and nulled");
console.log("testReloadWidgets cleared");
const result = await this.getWidgets();
console.log("testReloadWidgets got", {result});
}

@ -1,11 +1,22 @@
import {allCommands} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeTestId} from 'app/client/lib/domUtils';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import * as kf from 'app/client/lib/koForm';
import {makeT} from 'app/client/lib/localization';
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
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 {hoverTooltip} from 'app/client/ui/tooltips';
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 {textInput} from 'app/client/ui2018/editableLabel';
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 {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
import {GristLoadConfig} from 'app/common/gristUrls';
import {not, unwrap} from 'app/common/gutil';
import {
bundleChanges,
Computed,
Disposable,
dom,
DomContents,
fromKo,
MultiHolder,
Observable,
@ -33,22 +43,8 @@ import {
const t = makeT('CustomSectionConfig');
// Custom URL widget id - used as mock id for selectbox.
const CUSTOM_ID = 'custom';
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 {
constructor(
private _value: Observable<number|number[]|null>,
@ -319,17 +315,17 @@ class ColumnListPicker extends Disposable {
}
class CustomSectionConfigurationConfig extends Disposable{
// Does widget has custom configuration.
private readonly _hasConfiguration: Computed<boolean>;
private readonly _hasConfiguration = Computed.create(this, use =>
Boolean(use(this._section.hasCustomOptions) || use(this._section.columnsToMap)));
constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {
super();
this._hasConfiguration = Computed.create(this, use => use(_section.hasCustomOptions));
}
public buildDom() {
// Show prompt, when desired access level is different from actual one.
return dom(
'div',
dom.maybe(this._hasConfiguration, () =>
return dom.maybe(this._hasConfiguration, () => [
cssSeparator(),
dom.maybe(this._section.hasCustomOptions, () =>
cssSection(
textButton(
t("Open configuration"),
@ -363,7 +359,7 @@ class CustomSectionConfigurationConfig extends Disposable{
: dom.create(ColumnPicker, m.value, m.column, this._section)),
);
})
);
]);
}
private _openConfiguration(): void {
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 {
protected _customSectionConfigurationConfig = new CustomSectionConfigurationConfig(
this._section, this._gristDoc);
protected _customSectionConfigurationConfig: CustomSectionConfigurationConfig;
// Holds all available widget definitions.
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) {
super();
this._customSectionConfigurationConfig = new CustomSectionConfigurationConfig(_section, _gristDoc);
// 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);
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) {
// selection id is "pluginId:widgetId"
const pluginId = use(this._section.customDef.pluginId);
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');
return CUSTOM_URL_WIDGET_ID;
}
// 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();
}
private readonly _isCustomUrlWidget = Computed.create(this, this._widgetId, (_use, widgetId) => {
return widgetId === CUSTOM_URL_WIDGET_ID;
});
private readonly _currentAccess = Computed.create(this, use =>
(use(this._section.customDef.access) as AccessLevel) || AccessLevel.none)
.onWrite(async newAccess => {
await this._section.customDef.access.setAndSave(newAccess);
});
// Url for the widget, taken either from widget definition, or provided by hand for Custom URL.
// For custom widget, we will store url also in section definition.
this._url = Computed.create(this, use => use(_section.customDef.url) || '');
this._url.onWrite(async newUrl => {
private readonly _desiredAccess = fromKo(this._section.desiredAccessLevel);
private readonly _url = Computed.create(this, use => use(this._section.customDef.url) || '')
.onWrite(async newUrl => {
bundleChanges(() => {
_section.customDef.renderAfterReady(false);
this._section.customDef.renderAfterReady(false);
if (newUrl) {
// When a URL is set explicitly, make sure widgetId/pluginId/widgetDef
// is empty.
_section.customDef.widgetId(null);
_section.customDef.pluginId('');
_section.customDef.widgetDef(null);
this._section.customDef.widgetId(null);
this._section.customDef.pluginId('');
this._section.customDef.widgetDef(null);
}
_section.customDef.url(newUrl);
this._section.customDef.url(newUrl);
});
await _section.saveCustomDef();
await this._section.saveCustomDef();
});
// Compute current access level.
this._currentAccess = Computed.create(
this,
use => (use(_section.customDef.access) as AccessLevel) || AccessLevel.none
);
this._currentAccess.onWrite(async newAccess => {
await _section.customDef.access.setAndSave(newAccess);
private readonly _requiresAccess = Computed.create(this, use => {
const [currentAccess, desiredAccess] = [use(this._currentAccess), use(this._desiredAccess)];
return desiredAccess && !isSatisfied(currentAccess, desiredAccess);
});
// From the start desired access level is the same as current one.
this._desiredAccess = fromKo(_section.desiredAccessLevel);
private readonly _widgetDetailsExpanded: Observable<boolean>;
private readonly _widgets: Observable<ICustomWidget[] | null> = Observable.create(this, null);
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.
this.autoDispose(_section.id.subscribe(() => this._reject()));
this.autoDispose(_section.id.subscribe(() => this._dismissAccessPrompt()));
}
public buildDom() {
// UI observables holder.
const holder = new MultiHolder();
// Show prompt, when desired access level is different from actual one.
const prompt = Computed.create(holder, use =>
use(this._desiredAccess)
&& !isSatisfied(use(this._currentAccess), use(this._desiredAccess)!));
// If this is empty section or not.
const isSelected = Computed.create(holder, use => Boolean(use(this._selectedId)));
// 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)
const options = Computed.create(holder, use => [
{label: 'Custom URL', value: 'custom'},
...(use(this._widgets) || [])
.filter(w => w?.published !== false)
.map(w => ({
label: w.source?.name ? `${w.name} (${w.source.name})` : w.name,
value: (w.source?.pluginId || '') + ':' + w.widgetId,
})),
]);
function buildPrompt(level: AccessLevel|null) {
if (!level) {
return null;
public buildDom(): DomContents {
return dom('div',
this._buildWidgetSelector(),
this._buildAccessLevelConfig(),
this._customSectionConfigurationConfig.buildDom(),
);
}
switch(level) {
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`);
protected shouldRenderWidgetSelector(): boolean {
return true;
}
protected async _getWidgets() {
return await this._gristDoc.app.topAppModel.getWidgets();
}
// 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},
private _buildWidgetSelector() {
if (!this.shouldRenderWidgetSelector()) { return null; }
return [
cssRow(
cssWidgetSelector(
this._buildShowWidgetDetailsButton(),
this._buildWidgetName(),
),
),
this._maybeBuildWidgetDetails(),
];
return dom(
'div',
dom.autoDispose(holder),
this.shouldRenderWidgetSelector() &&
this._canSelect
? cssRow(
select(this._selectedId, options, {
defaultLabel: t("Select Custom Widget"),
menuCssClass: cssMenu.className,
}
private _buildShowWidgetDetailsButton() {
return cssShowWidgetDetails(
cssShowWidgetDetailsIcon(
'Dropdown',
cssShowWidgetDetailsIcon.cls('-collapsed', use => !use(this._widgetDetailsExpanded)),
testId('toggle-custom-widget-details'),
testId(use => !use(this._widgetDetailsExpanded)
? 'show-custom-widget-details'
: 'hide-custom-widget-details'
),
),
cssWidgetLabel(t('Widget')),
dom.on('click', () => {
this._widgetDetailsExpanded.set(!this._widgetDetailsExpanded.get());
}),
testId('select')
)
: null,
dom.maybe((use) => use(isCustom) && this.shouldRenderWidgetSelector(), () => [
);
}
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(
this._buildWidgetDetails(widget),
)
)
);
}
private _buildWidgetDetails(widget: ICustomWidget | null) {
return dom.domComputed(this._isCustomUrlWidget, (isCustomUrlWidget) => {
if (isCustomUrlWidget) {
return cssCustomUrlDetails(
cssTextInput(
this._url,
async value => this._url.set(value),
dom.attr('placeholder', t("Enter Custom URL")),
testId('url')
dom.show(this._isCustomUrlWidget),
{placeholder: t('Enter Custom URL')},
),
this._gristDoc.behavioralPromptsManager.attachPopup('customURL', {
popupOptions: {
placement: 'left-start',
},
isDisabled: () => {
// Disable tip if a custom widget is already selected.
return Boolean(this._selectedId.get() && !(isCustom.get() && this._url.get().trim() === ''));
},
})
);
} else if (!widget?.description && !widget?.authors?.[0] && !widget?.lastUpdatedAt) {
return cssDetailsMessage(t('Missing description and author information.'));
} else {
return cssWidgetDetails(
!widget?.description ? null : cssWidgetDescription(
widget.description,
testId('custom-widget-description'),
),
]),
dom.maybe(prompt, () =>
kf.prompt(
{tabindex: '-1'},
cssWidgetMetadata(
!widget?.authors?.[0] ? null : cssWidgetMetadataRow(
cssWidgetMetadataName(t('Developer:')),
cssWidgetMetadataValue(
widget.authors[0].url
? cssDeveloperLink(
widget.authors[0].name,
{href: widget.authors[0].url, target: '_blank'},
testId('custom-widget-developer'),
)
: 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',
dom('div',
cssConfirmRow(
dom.domComputed(this._desiredAccess, (level) => buildPrompt(level))
dom.domComputed(this._desiredAccess, (level) => this._buildAccessLevelPrompt(level))
),
cssConfirmRow(
primaryButton(
'Accept',
t('Accept'),
testId('access-accept'),
dom.on('click', () => this._accept())
dom.on('click', () => this._grantDesiredAccess())
),
basicButton(
'Reject',
t('Reject'),
testId('access-reject'),
dom.on('click', () => this._reject())
)
)
dom.on('click', () => this._dismissAccessPrompt())
)
)
)
),
dom.maybe(
use => use(isSelected) || !this._canSelect,
() => [
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(),
);
dom.onKeyDown({
Enter: () => this._grantDesiredAccess(),
Escape:() => this._dismissAccessPrompt(),
}),
)),
];
}
protected shouldRenderWidgetSelector(): boolean {
return true;
}
private _buildAccessLevelPrompt(level: AccessLevel | null) {
if (!level) { return null; }
protected async _getWidgets() {
await this._gristDoc.app.topAppModel.getWidgets();
switch (level) {
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()) {
this._currentAccess.set(this._desiredAccess.get()!);
}
this._reject();
this._dismissAccessPrompt();
}
private _reject() {
private _dismissAccessPrompt() {
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', `
padding-left: 8px;
padding-top: 6px;
@ -700,12 +702,6 @@ const cssSection = styled('div', `
margin: 16px 16px 12px 16px;
`);
const cssMenu = styled('div', `
& > li:first-child {
border-bottom: 1px solid ${theme.menuBorder};
}
`);
const cssAddIcon = styled(icon, `
margin-right: 4px;
`);
@ -748,17 +744,9 @@ const cssAddMapping = styled('div', `
`);
const cssTextInput = styled(textInput, `
flex: 1 0 auto;
color: ${theme.inputFg};
background-color: ${theme.inputBg};
&:disabled {
color: ${theme.inputDisabledFg};
background-color: ${theme.inputDisabledBg};
pointer-events: none;
}
&::placeholder {
color: ${theme.inputPlaceholderFg};
}
@ -771,3 +759,62 @@ const cssDisabledSelect = styled(select, `
const cssBlank = styled(cssOptionLabel, `
--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'
| 'accessRulesTableWide'
| 'setChoiceDropdownCondition'
| 'setRefDropdownCondition';
| 'setRefDropdownCondition'
| 'communityWidgets';
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
@ -152,6 +153,15 @@ see or edit which parts of your document.')
),
...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 {
@ -307,20 +317,6 @@ to determine who can see or edit which parts of your document.')),
forceShow: true,
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: {
popupType: 'tip',
title: () => t('Calendar'),

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

@ -29,6 +29,7 @@ import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {reportError} from 'app/client/models/AppModel';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
import {GridOptions} from 'app/client/ui/GridOptions';
@ -526,7 +527,7 @@ export class RightPanel extends Disposable {
dom.maybe((use) => use(this._pageWidgetType) === 'custom', () => {
const parts = vct._buildCustomTypeItems() as any[];
return [
cssLabel(t("CUSTOM")),
cssSeparator(),
// 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
// the only one that will be shown without the feature flag.
@ -880,13 +881,20 @@ export class RightPanel extends Disposable {
private _createPageWidgetPicker(): DomElementMethod {
const gristDoc = this._gristDoc;
const section = gristDoc.viewModel.activeSection;
const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val);
return (elem) => { attachPageWidgetPicker(elem, gristDoc, onSave, {
const {activeSection} = gristDoc.viewModel;
const onSave = async (val: IPageWidget) => {
const {id} = await gristDoc.saveViewSection(activeSection.peek(), val);
if (val.type === 'custom') {
showCustomWidgetGallery(gristDoc, {sectionRef: id()});
}
};
return (elem) => {
attachPageWidgetPicker(elem, gristDoc, onSave, {
buttonLabel: t("Save"),
value: () => toPageWidget(section.peek()),
value: () => toPageWidget(activeSection.peek()),
selectBy: (val) => gristDoc.selectBy(val),
}); };
});
};
}
// 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.
// It is expected that the elem arg has the offsetHeight property set.
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', `

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

@ -471,6 +471,10 @@ export const theme = {
undefined, colors.mediumGreyOpaque),
rightPanelFieldSettingsButtonBg: new CustomProp('theme-right-panel-field-settings-button-bg',
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 */
documentHistorySnapshotFg: new CustomProp('theme-document-history-snapshot-fg', undefined,
@ -877,6 +881,20 @@ export const theme = {
/* Numeric Spinners */
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');

@ -30,12 +30,10 @@ export interface ICustomWidget {
* applying the Grist theme.
*/
renderAfterReady?: boolean;
/**
* If set to false, do not offer to user in UI.
*/
published?: boolean;
/**
* If the widget came from a plugin, we track that here.
*/
@ -43,6 +41,29 @@ export interface ICustomWidget {
pluginId: 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',
'addNew',
'rickRow',
'customURL',
'calendarConfig',
// The following were used in the past and should not be re-used.
// 'customURL',
// 'formsAreHere',
);
export type BehavioralPrompt = typeof BehavioralPrompt.type;

@ -211,6 +211,8 @@ export const ThemeColors = t.iface([], {
"right-panel-toggle-button-disabled-bg": "string",
"right-panel-field-settings-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-selected-fg": "string",
"document-history-snapshot-bg": "string",
@ -438,6 +440,13 @@ export const ThemeColors = t.iface([], {
"scroll-shadow": "string",
"toggle-checkbox-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 = {

@ -269,6 +269,8 @@ export interface ThemeColors {
'right-panel-toggle-button-disabled-bg': string;
'right-panel-field-settings-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-snapshot-fg': string;
@ -572,6 +574,15 @@ export interface ThemeColors {
/* Numeric Spinners */
'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>;

@ -759,7 +759,8 @@ export interface GristLoadConfig {
// List of registered plugins (used by HomePluginManager and DocPluginManager)
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;
// 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-field-settings-bg': '#404150',
'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-snapshot-fg': '#EFEFEF',
@ -551,4 +553,13 @@ export const GristDark: ThemeColors = {
/* Numeric Spinners */
'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-field-settings-bg': '#E8E8E8',
'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-snapshot-fg': '#262633',
@ -551,4 +553,13 @@ export const GristLight: ThemeColors = {
/* Numeric Spinners */
'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-PublicColor: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTQiIHdpZHRoPSIxNiI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIG9wYWNpdHk9Ii43ODMiPjxwYXRoIGQ9Ik0gMTUuMjc1LDEwLjI5MyAxMy4wODcsOS42NjggQyAxMi43MjY0NzgsOS41NjQ5MTc4IDEyLjQ1MzkyLDkuMjY4ODEwNSAxMi4zODEsOC45MDEgTCAxMi4yNDcsOC4yMjYgQyAxMy4zMTQxMiw3LjczODMwNjEgMTMuOTk5MDAyLDYuNjczMjgxNyAxNCw1LjUgViA0LjEyNiBDIDE0LjAyNTE5OCwyLjQ3MzAwMzMgMTIuNzQyNTA2LDEuMDk0MTUzNCAxMS4wOTIsMSA5Ljg3OTM3MDksMC45NjM0MTA4MiA4Ljc2NDA2NTUsMS42NjA3NzI3IDguMjY2LDIuNzY3IDguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4LDQuMjgzNjI0NyA5LDUuMTI2IFYgNi41IEMgOC45OTgyMDc3LDYuODYzNzcwNiA4Ljk0NjA0NTYsNy4yMjU1NDA0IDguODQ1LDcuNTc1IDkuMTA0MjMyNSw3Ljg0Njk0MjMgOS40MTIyMzk1LDguMDY3NzcxMSA5Ljc1Myw4LjIyNiBMIDkuNjE5LDguOSBDIDkuNTQ2MDc5Niw5LjI2NzgxMDUgOS4yNzM1MjE5LDkuNTYzOTE3OCA4LjkxMyw5LjY2NyBMIDguMDcsOS45MDggOS41NSwxMC4zMzEgYyAwLjg1Njk2NCwwLjI0NzU1NCAxLjQ0NzcwNiwxLjAzMDk5OSAxLjQ1LDEuOTIzIFYgMTQuNSBjIC0wLjAwMTcsMC4xNzA3MiAtMC4wMzI3OCwwLjMzOTg3MSAtMC4wOTIsMC41IEggMTUuNSBjIDAuMjc2MTQyLDAgMC41LC0wLjIyMzg1OCAwLjUsLTAuNSB2IC0zLjI0NiBjIC0xLjk4ZS00LC0wLjQ0NjIwOCAtMC4yOTU5OTYsLTAuODM4MjkzIC0wLjcyNSwtMC45NjEgeiIgZmlsbD0iI2U2YTExNyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNIDkuMjc1LDExLjI5MyA3LjA4NywxMC42NjggQyA2LjcyNjIwMjMsMTAuNTY0NzU0IDYuNDUzNTgzLDEwLjI2ODE5NCA2LjM4MSw5LjkgTCA2LjI0Nyw5LjIyNSBDIDcuMzEzNzkxNCw4LjczNzQ1NjkgNy45OTg2MTEyLDcuNjcyOTE5NSA4LDYuNSBWIDUuMTI2IEMgOC4wMjUxOTc2LDMuNDczMDAzMyA2Ljc0MjUwNTUsMi4wOTQxNTM0IDUuMDkyLDIgNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgzIDIuOTEwOTUzMiwyLjg0NTQ3ODUgMi4zMjgyODYzLDMuNDEwODA4NiAxLjk5OTYxODIsNC4xODgxNTA5IDIsNSB2IDEuNSBjIDkuOTc5ZS00LDEuMTczMjgxNyAwLjY4NTg3OTcsMi4yMzgzMDYyIDEuNzUzLDIuNzI2IEwgMy42MTksOS45IEMgMy41NDYwOCwxMC4yNjc4MTEgMy4yNzM1MjE5LDEwLjU2MzkxOCAyLjkxMywxMC42NjcgTCAwLjcyNSwxMS4yOTIgQyAwLjI5NTYzOTI2LDExLjQxNDgwOSAtMi40ODE3NzE2ZS00LDExLjgwNzQyMSAwLDEyLjI1NCBWIDE0LjUgQyAwLDE0Ljc3NjE0MiAwLjIyMzg1NzYzLDE1IDAuNSwxNSBoIDkgQyA5Ljc3NjE0MjQsMTUgMTAsMTQuNzc2MTQyIDEwLDE0LjUgViAxMi4yNTQgQyA5Ljk5OTgwMTgsMTEuODA3NzkyIDkuNzA0MDA0MywxMS40MTU3MDcgOS4yNzUsMTEuMjkzIFoiIGZpbGw9IiNmZmYiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgLTEpIi8+PC9nPjwvZz48L3N2Zz4=');
--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-Remove: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYsNCBMNiwyLjUgQzYsMi4yMjM4NTc2MyA2LjIyMzg1NzYzLDIgNi41LDIgTDkuNSwyIEM5Ljc3NjE0MjM3LDIgMTAsMi4yMjM4NTc2MyAxMCwyLjUgTDEwLDQgTDEzLjUsNCBDMTMuNzc2MTQyNCw0IDE0LDQuMjIzODU3NjMgMTQsNC41IEMxNCw0Ljc3NjE0MjM3IDEzLjc3NjE0MjQsNSAxMy41LDUgTDIuNSw1IEMyLjIyMzg1NzYzLDUgMiw0Ljc3NjE0MjM3IDIsNC41IEMyLDQuMjIzODU3NjMgMi4yMjM4NTc2Myw0IDIuNSw0IEw2LDQgWiBNNyw0IEw5LDQgTDksMyBMNywzIEw3LDQgWiBNMTEsNi41IEMxMSw2LjIyMzg1NzYzIDExLjIyMzg1NzYsNiAxMS41LDYgQzExLjc3NjE0MjQsNiAxMiw2LjIyMzg1NzYzIDEyLDYuNSBMMTIsMTIuNSBDMTIsMTMuMzI4NDI3MSAxMS4zMjg0MjcxLDE0IDEwLjUsMTQgTDUuNSwxNCBDNC42NzE1NzI4OCwxNCA0LDEzLjMyODQyNzEgNCwxMi41IEw0LDYuNSBDNCw2LjIyMzg1NzYzIDQuMjIzODU3NjMsNiA0LjUsNiBDNC43NzYxNDIzNyw2IDUsNi4yMjM4NTc2MyA1LDYuNSBMNSwxMi41IEM1LDEyLjc3NjE0MjQgNS4yMjM4NTc2MywxMyA1LjUsMTMgTDEwLjUsMTMgQzEwLjc3NjE0MjQsMTMgMTEsMTIuNzc2MTQyNCAxMSwxMi41IEwxMSw2LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--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 = '';
// Holds widgets manifest content.
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(){
// 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 () {
await buildWidgetServer();
oldEnv = new EnvironmentSnapshot();
process.env.GRIST_WIDGET_LIST_URL = `${widgetServerUrl}${manifestEndpoint}`;
process.env.PERMITTED_CUSTOM_WIDGETS = "calendar";
await server.restart();
await useManifest(manifestEndpoint);
const session = await gu.session().login();
await session.tempDoc(cleanup, 'Hello.grist');
});
after(async function () {

@ -145,18 +145,6 @@ describe('BehavioralPrompts', function() {
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() {
it('should not be shown if site is empty', async function() {
session = await gu.session().user('user4').login({showTips: true});

@ -1,25 +1,12 @@
import {safeJsonParse} from 'app/common/gutil';
import * as chai from 'chai';
import {assert, driver, Key} from 'mocha-webdriver';
import {serveCustomViews, Serving, setAccess} from 'test/nbrowser/customUtil';
import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import { serveCustomViews, Serving, setAccess } from 'test/nbrowser/customUtil';
import * as chai from 'chai';
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() {
this.timeout(20000);
gu.bigScreen();
@ -49,9 +36,8 @@ describe('CustomView', function() {
await gu.addNewSection('Custom', 'Table1');
// 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 driver.find('.test-config-widget-url').click();
await gu.sendKeys(`${serving.url}/deferred-ready`, Key.ENTER);
// We should have a single iframe.
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
// as JSON.
await driver.find('.test-config-widget').click();
await setCustomWidget();
await driver.find('.test-config-widget-url').click();
await driver.sendKeys(`${serving.url}/readout`, Key.ENTER);
await gu.setCustomWidgetUrl(`${serving.url}/readout`, {openGallery: false});
await gu.openWidgetPanel();
await setAccess(access);
await gu.waitForServer();
@ -167,10 +151,8 @@ describe('CustomView', function() {
await gu.waitForServer();
// Choose the custom view that just reads out data as json
await driver.find('.test-config-widget').click();
await setCustomWidget();
await driver.find('.test-config-widget-url').click();
await driver.sendKeys(`${serving.url}/readout`, Key.ENTER);
await gu.setCustomWidgetUrl(`${serving.url}/readout`, {openGallery: false});
await gu.openWidgetPanel();
await setAccess(access);
await gu.waitForServer();
@ -265,7 +247,7 @@ describe('CustomView', function() {
it('allows switching to custom section by clicking inside it', async function() {
await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click();
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');
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.
await driver.switchTo().defaultContent();
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.
await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click();
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() {
// Choose a custom widget that tries to replace all cells in all user tables with 'zap'.
await gu.getSection('Friends Custom').click();
await driver.find('.test-config-widget').click();
await setAccess("none");
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 gu.setCustomWidgetUrl(`${serving.url}/zap`);
await gu.openWidgetPanel();
await setAccess(access);
await gu.waitForServer();
@ -329,12 +306,10 @@ describe('CustomView', function() {
// The test doc already has a Custom View widget. It just needs to
// have a URL set.
await gu.getSection('TYPES custom').click();
await driver.find('.test-config-widget').click();
await setCustomWidget();
await gu.setCustomWidgetUrl(`${serving.url}/types`);
// If we needed to change widget to Custom URL, make sure access is read table.
await setAccess("read table");
await driver.find('.test-config-widget-url').click();
await driver.sendKeys(`${serving.url}/types`, Key.ENTER);
await gu.waitForServer();
const iframe = gu.getSection('TYPES custom').find('iframe');
await driver.switchTo().frame(iframe);
@ -480,10 +455,8 @@ describe('CustomView', function() {
await gu.waitForServer();
// Select a custom widget that tries to replace all cells in all user tables with 'zap'.
await driver.find('.test-config-widget').click();
await setCustomWidget();
await driver.find('.test-config-widget-url').click();
await driver.sendKeys(`${serving.url}/zap`, Key.ENTER);
await gu.setCustomWidgetUrl(`${serving.url}/zap`, {openGallery: false});
await gu.openWidgetPanel();
await setAccess("full");
await gu.waitForServer();
@ -537,10 +510,10 @@ describe('CustomView', function() {
const doc = await mainSession.tempDoc(cleanup, 'FetchSelectedOptions.grist', {load: false});
await mainSession.loadDoc(`/doc/${doc.id}`);
await gu.toggleSidePanel('right', 'open');
await gu.getSection('TABLE1 Custom').click();
await driver.find('.test-config-widget-url').click();
await gu.sendKeys(`${serving.url}/fetchSelectedOptions`, Key.ENTER);
await gu.setCustomWidgetUrl(`${serving.url}/fetchSelectedOptions`);
await gu.openWidgetPanel();
await setAccess("full");
await gu.waitForServer();
const expected = {
@ -620,8 +593,10 @@ describe('CustomView', function() {
}
await inFrame(async () => {
await gu.waitToPass(async () => {
const parsed = await getData(12);
assert.deepEqual(parsed, expected);
}, 1000);
});
// Change the access level away from 'full'.

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

@ -1,8 +1,9 @@
import {AccessLevel} from 'app/common/CustomWidget';
import {addToRepl, assert, driver, Key} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import {addStatic, serveSomething} from 'test/server/customUtil';
import {AccessLevel} from 'app/common/CustomWidget';
import {EnvironmentSnapshot} from 'test/server/testUtils';
// Valid manifest url.
const manifestEndpoint = '/manifest.json';
@ -16,7 +17,7 @@ const READ_WIDGET = 'Read';
const FULL_WIDGET = 'Full';
const COLUMN_WIDGET = 'COLUMN_WIDGET';
const REQUIRED_WIDGET = 'REQUIRED_WIDGET';
// Custom URL label in selectbox.
// Custom URL label.
const CUSTOM_URL = 'Custom URL';
// Holds url for sample widget server.
let widgetServerUrl = '';
@ -27,14 +28,9 @@ function createConfigUrl(ready?: any) {
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 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());
// 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) => {
await driver.findContent('.test-select-menu li', text).click();
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());
}
// When refreshing, we need to make sure widget repository is enabled once again.
async function refresh() {
await driver.navigate().refresh();
await gu.waitForDocToLoad();
// Switch section and enable config
await gu.selectSectionByTitle('Table');
await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
await gu.selectSectionByTitle('Widget');
}
@ -130,6 +124,7 @@ describe('CustomWidgetsConfig', function () {
let mainSession: gu.Session;
gu.bigScreen();
let oldEnv: EnvironmentSnapshot;
addToRepl('getOptions', getOptions);
@ -137,6 +132,12 @@ describe('CustomWidgetsConfig', function () {
if (server.isExternalServer()) {
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.
const widgetServer = await serveSomething(app => {
app.get('/manifest.json', (_, res) => {
@ -188,25 +189,23 @@ describe('CustomWidgetsConfig', function () {
mainSession = await gu.session().login();
const doc = await mainSession.tempDoc(cleanup, 'CustomWidget.grist');
docId = doc.id;
// Make sure widgets are enabled.
await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
await gu.toggleSidePanel('right', 'open');
await gu.selectSectionByTitle('Widget');
});
after(async function() {
await server.testingHooks.setWidgetRepositoryUrl('');
oldEnv.restore();
await server.restart();
});
beforeEach(async () => {
// Before each test, we will switch to Custom Url (to cleanup the widget)
// and then back to the Tester widget.
if ((await currentWidget()) !== CUSTOM_URL) {
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
if ((await gu.getCustomWidgetName()) !== CUSTOM_URL) {
await gu.setCustomWidget(CUSTOM_URL);
}
await toggleWidgetMenu();
await clickOption(TESTER_WIDGET);
await gu.setCustomWidget(TESTER_WIDGET);
await widget.waitForFrame();
});
@ -218,8 +217,7 @@ describe('CustomWidgetsConfig', function () {
assert.isFalse(await driver.find('.test-custom-widget-ready').isPresent());
// Now select the widget that requires a column.
await toggleWidgetMenu();
await clickOption(REQUIRED_WIDGET);
await gu.setCustomWidget(REQUIRED_WIDGET);
await gu.acceptAccessRequest();
// 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 () => {
if ((await currentWidget()) !== CUSTOM_URL) {
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
}
await gu.setWidgetUrl(
await gu.setCustomWidgetUrl(
createConfigUrl({
columns: [{name: 'M2', type: 'Date', optional: true}],
requiredAccess: 'read table',
})
}),
);
await widget.waitForFrame();
@ -307,11 +301,7 @@ describe('CustomWidgetsConfig', function () {
it('should clear optional mapping', async () => {
const revert = await gu.begin();
if ((await currentWidget()) !== CUSTOM_URL) {
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
}
await gu.setWidgetUrl(
await gu.setCustomWidgetUrl(
createConfigUrl({
columns: [{name: 'M2', type: 'Date', optional: true}],
requiredAccess: 'read table',
@ -356,9 +346,7 @@ describe('CustomWidgetsConfig', function () {
it('should render columns mapping', async () => {
const revert = await gu.begin();
assert.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
await toggleWidgetMenu();
// Select widget that has single column configuration.
await clickOption(COLUMN_WIDGET);
await gu.setCustomWidget(COLUMN_WIDGET);
await widget.waitForFrame();
await gu.acceptAccessRequest();
await widget.waitForPendingRequests();
@ -386,11 +374,9 @@ describe('CustomWidgetsConfig', function () {
it('should render multiple mappings', async () => {
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
// and is using it to invoke the ready method.
await gu.setWidgetUrl(
await gu.setCustomWidgetUrl(
createConfigUrl({
columns: ['M1', {name: 'M2', optional: true}, {name: 'M3', title: 'T3'}, {name: 'M4', type: 'Text'}],
requiredAccess: 'read table',
@ -448,8 +434,7 @@ describe('CustomWidgetsConfig', function () {
it('should clear mappings on widget switch', async () => {
const revert = await gu.begin();
await toggleWidgetMenu();
await clickOption(COLUMN_WIDGET);
await gu.setCustomWidget(COLUMN_WIDGET);
await widget.waitForFrame();
await gu.acceptAccessRequest();
await widget.waitForPendingRequests();
@ -466,8 +451,7 @@ describe('CustomWidgetsConfig', function () {
await clickOption('A');
// Now change to a widget without columns
await toggleWidgetMenu();
await clickOption(NORMAL_WIDGET);
await gu.setCustomWidget(NORMAL_WIDGET);
// Picker should disappear and column mappings should be visible
assert.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
@ -481,8 +465,7 @@ describe('CustomWidgetsConfig', function () {
{id: 3, A: 'C'},
]);
// Now go back to the widget with mappings.
await toggleWidgetMenu();
await clickOption(COLUMN_WIDGET);
await gu.setCustomWidget(COLUMN_WIDGET);
await widget.waitForFrame();
await gu.acceptAccessRequest();
await widget.waitForPendingRequests();
@ -494,9 +477,7 @@ describe('CustomWidgetsConfig', function () {
it('should render multiple options', async () => {
const revert = await gu.begin();
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
await gu.setWidgetUrl(
await gu.setCustomWidgetUrl(
createConfigUrl({
columns: [
{name: 'M1', allowMultiple: true, optional: true},
@ -578,9 +559,7 @@ describe('CustomWidgetsConfig', function () {
it('should support multiple types in mappings', async () => {
const revert = await gu.begin();
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
await gu.setWidgetUrl(
await gu.setCustomWidgetUrl(
createConfigUrl({
columns: [
{name: 'M1', type: 'Date,DateTime', optional: true},
@ -639,9 +618,7 @@ describe('CustomWidgetsConfig', function () {
it('should support strictType setting', async () => {
const revert = await gu.begin();
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
await gu.setWidgetUrl(
await gu.setCustomWidgetUrl(
createConfigUrl({
columns: [
{name: 'Any', type: 'Any', strictType: true, optional: true},
@ -683,9 +660,7 @@ describe('CustomWidgetsConfig', function () {
it('should react to widget options change', async () => {
const revert = await gu.begin();
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
await gu.setWidgetUrl(
await gu.setCustomWidgetUrl(
createConfigUrl({
columns: [
{name: 'Choice', type: 'Choice', strictType: true, optional: true},
@ -731,10 +706,8 @@ describe('CustomWidgetsConfig', function () {
it('should remove mapping when column is deleted', async () => {
const revert = await gu.begin();
await toggleWidgetMenu();
// Prepare mappings for single and multiple columns
await clickOption(CUSTOM_URL);
await gu.setWidgetUrl(
await gu.setCustomWidgetUrl(
createConfigUrl({
columns: [{name: 'M1', optional: true}, {name: 'M2', allowMultiple: true, optional: true}],
requiredAccess: 'read table',
@ -820,10 +793,8 @@ describe('CustomWidgetsConfig', function () {
it('should remove mapping when column type is changed', async () => {
const revert = await gu.begin();
await toggleWidgetMenu();
// Prepare mappings for single and multiple columns
await clickOption(CUSTOM_URL);
await gu.setWidgetUrl(
await gu.setCustomWidgetUrl(
createConfigUrl({
columns: [
{name: 'M1', type: 'Text', optional: true},
@ -900,10 +871,9 @@ describe('CustomWidgetsConfig', function () {
await gu.undo();
// Add Custom - no section option by default
await gu.addNewSection(/Custom/, /Table1/);
await gu.addNewSection(/Custom/, /Table1/, {customWidget: /Custom URL/});
assert.isFalse(await hasSectionOption());
await toggleWidgetMenu();
await clickOption(TESTER_WIDGET);
await gu.setCustomWidget(TESTER_WIDGET);
assert.isTrue(await hasSectionOption());
await gu.undo(2);
});
@ -1058,30 +1028,19 @@ describe('CustomWidgetsConfig', function () {
it('should show options action button', async () => {
// Select widget without options
await toggleWidgetMenu();
await clickOption(NORMAL_WIDGET);
await gu.setCustomWidget(NORMAL_WIDGET);
assert.isFalse(await hasSectionOption());
// Select widget with options
await toggleWidgetMenu();
await clickOption(TESTER_WIDGET);
await gu.setCustomWidget(TESTER_WIDGET);
assert.isTrue(await hasSectionOption());
// Select widget without options
await toggleWidgetMenu();
await clickOption(NORMAL_WIDGET);
await gu.setCustomWidget(NORMAL_WIDGET);
assert.isFalse(await hasSectionOption());
});
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.
await toggleWidgetMenu();
await clickOption(READ_WIDGET);
await gu.setCustomWidget(READ_WIDGET);
await widget.waitForFrame();
assert.isTrue(await gu.hasAccessPrompt());
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 widget.access(), AccessLevel.read_table);
// Select widget that requests full access.
await toggleWidgetMenu();
await clickOption(FULL_WIDGET);
await gu.setCustomWidget(FULL_WIDGET);
await widget.waitForFrame();
assert.isTrue(await gu.hasAccessPrompt());
assert.equal(await gu.widgetAccess(), AccessLevel.none);
@ -1101,7 +1059,7 @@ describe('CustomWidgetsConfig', function () {
await widget.waitForPendingRequests();
assert.equal(await gu.widgetAccess(), 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 () => {
@ -1265,7 +1223,6 @@ const widget = {
* any existing widget state (even if the Custom URL was already selected).
*/
async resetWidget() {
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
await gu.setCustomWidget(CUSTOM_URL);
}
};

@ -219,16 +219,10 @@ describe('LinkingBidirectional', 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', {});
// Rename this section as Data.
await gu.renameActiveSection('Data');
// Add new custom section with a widget.
await gu.addNewSection('Custom', 'Classes', { selectBy: 'Data' });
// Rename this section as Custom.
await gu.addNewSection('Custom', 'Classes', {customWidget: /Custom URL/, selectBy: 'Data'});
await gu.renameActiveSection('Custom');
// Make sure it can be used as a filter.

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

@ -107,8 +107,8 @@ describe("ViewLayoutCollapse", function() {
addStatic(app);
});
cleanup.addAfterAll(widgetServer.shutdown);
await gu.setCustomWidgetUrl(widgetServer.url + '/probe/index.html', {openGallery: false});
await gu.openWidgetPanel();
await gu.setWidgetUrl(widgetServer.url + '/probe/index.html');
await gu.widgetAccess(AccessLevel.full);
// Collapse it.
@ -146,8 +146,8 @@ describe("ViewLayoutCollapse", function() {
addStatic(app);
});
cleanup.addAfterAll(widgetServer.shutdown);
await gu.setCustomWidgetUrl(widgetServer.url + '/probe/index.html', {openGallery: false});
await gu.openWidgetPanel();
await gu.setWidgetUrl(widgetServer.url + '/probe/index.html');
await gu.widgetAccess(AccessLevel.full);
// Collapse it.

@ -3242,17 +3242,52 @@ export async function renameActiveTable(name: string) {
await waitForServer();
}
export async function setWidgetUrl(url: string) {
await driver.find('.test-config-widget-url').click();
// First clear textbox.
await clearInput();
if (url) {
await sendKeys(url);
export async function getCustomWidgetName() {
await openWidgetPanel();
return await driver.find('.test-config-widget-open-custom-widget-gallery').getText();
}
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 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' |
'Convert columns to data';
/**

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

Loading…
Cancel
Save