diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 5c7f2f7f..516bc14d 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -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!); - } - }); - - await this.openDocPage(viewRef!); - if (sectionRef) { - // The newly-added section should be given focus. - this.viewModel.activeSectionId(sectionRef); + const {table, type} = val; + let tableId: string | null | undefined; + if (table === 'New Table') { + tableId = await this._promptForName(); + if (tableId === undefined) { return; } } - - this._maybeShowEditCardLayoutTip(val.type).catch(reportError); - - if (AttachedCustomWidgets.guard(val.type)) { - this._handleNewAttachedCustomWidget(val.type).catch(reportError); + if (type === 'custom') { + return showCustomWidgetGallery(this, { + addWidget: () => this._addPage(val, tableId ?? null) as Promise<{ + viewRef: number; + sectionRef: number; + }>, + }); } + + const {sectionRef, viewRef} = await this.docData.bundleActions( + 'Add new page', + () => this._addPage(val, tableId ?? null) + ); + await this._focus({sectionRef, viewRef}); + this._showNewWidgetPopups(type); } /** @@ -1460,6 +1408,90 @@ export class GristDoc extends DisposableWithEvents { return values; } + 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); diff --git a/app/client/components/WidgetFrame.ts b/app/client/components/WidgetFrame.ts index 8a2b1bb8..ad7c24e6 100644 --- a/app/client/components/WidgetFrame.ts +++ b/app/client/components/WidgetFrame.ts @@ -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. diff --git a/app/client/lib/koForm.css b/app/client/lib/koForm.css index 3cb042a5..968d07ac 100644 --- a/app/client/lib/koForm.css +++ b/app/client/lib/koForm.css @@ -134,6 +134,10 @@ div:hover > .kf_tooltip { z-index: 11; } +.kf_prompt_content:focus { + outline: none; +} + .kf_draggable { display: inline-block; } diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index 141bc4bc..addb7008 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -62,8 +62,6 @@ export interface TopAppModel { orgs: Observable; users: Observable; - customWidgets: Observable; - // 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(this, []); public readonly users = Observable.create(this, []); public readonly plugins: LocalPlugin[] = []; - public readonly customWidgets = Observable.create(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; - 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(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}); } diff --git a/app/client/ui/CustomSectionConfig.ts b/app/client/ui/CustomSectionConfig.ts index b05fc6bf..9163bb11 100644 --- a/app/client/ui/CustomSectionConfig.ts +++ b/app/client/ui/CustomSectionConfig.ts @@ -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, @@ -319,17 +315,17 @@ class ColumnListPicker extends Disposable { } class CustomSectionConfigurationConfig extends Disposable{ - // Does widget has custom configuration. - private readonly _hasConfiguration: Computed; + 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); + + private readonly _widgetId = Computed.create(this, use => { + // Stored in one of two places, depending on age of document. + const widgetId = use(this._section.customDef.widgetId) || + use(this._section.customDef.widgetDef)?.widgetId; + if (widgetId) { + const pluginId = use(this._section.customDef.pluginId); + return (pluginId || '') + ':' + widgetId; + } else { + return CUSTOM_URL_WIDGET_ID; + } + }); - protected _customSectionConfigurationConfig: CustomSectionConfigurationConfig; - // Holds all available widget definitions. - private _widgets: Observable; - // Holds selected option (either custom string or a widgetId). - private readonly _selectedId: Computed; - // Holds custom widget URL. - private readonly _url: Computed; - // 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; - // Current access level (stored inside a section). - private readonly _currentAccess: Computed; - - - + private readonly _isCustomUrlWidget = Computed.create(this, this._widgetId, (_use, widgetId) => { + return widgetId === CUSTOM_URL_WIDGET_ID; + }); - 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); - if (widgetId) { - // selection id is "pluginId:widgetId" - return (pluginId || '') + ':' + widgetId; - } - return CUSTOM_ID; - }); - this._selectedId.onWrite(async value => { - if (value === CUSTOM_ID) { - // Select Custom URL - bundleChanges(() => { - // Reset whether widget should render after `grist.ready()`. - _section.customDef.renderAfterReady(false); - // Clear url. - _section.customDef.url(null); - // Clear widgetId - _section.customDef.widgetId(null); - _section.customDef.widgetDef(null); - // Clear pluginId - _section.customDef.pluginId(''); - // Reset access level to none. - _section.customDef.access(AccessLevel.none); - // Clear all saved options. - _section.customDef.widgetOptions(null); - // Reset custom configuration flag. - _section.hasCustomOptions(false); - // Clear column mappings. - _section.customDef.columnsMapping(null); - _section.columnsToMap(null); - this._desiredAccess.set(AccessLevel.none); - }); - await _section.saveCustomDef(); - } else { - const [pluginId, widgetId] = value?.split(':') || []; - // Select Widget - const selectedWidget = matchWidget(this._widgets.get()||[], { - widgetId, - pluginId, - }); - if (!selectedWidget) { - // should not happen - throw new Error('Error accessing widget from the list'); - } - // If user selected the same one, do nothing. - if (_section.customDef.widgetId.peek() === widgetId && - _section.customDef.pluginId.peek() === pluginId) { - return; - } - bundleChanges(() => { - // Reset whether widget should render after `grist.ready()`. - _section.customDef.renderAfterReady(selectedWidget.renderAfterReady ?? false); - // Clear access level - _section.customDef.access(AccessLevel.none); - // When widget wants some access, set desired access level. - this._desiredAccess.set(selectedWidget.accessLevel || AccessLevel.none); - - // Keep a record of the original widget definition. - // Don't rely on this much, since the document could - // have moved installation since, and widgets could be - // served from elsewhere. - _section.customDef.widgetDef(selectedWidget); - - // Update widgetId. - _section.customDef.widgetId(selectedWidget.widgetId); - // Update pluginId. - _section.customDef.pluginId(selectedWidget.source?.pluginId || ''); - // Update widget URL. Leave blank when widgetId is set. - _section.customDef.url(null); - // Clear options. - _section.customDef.widgetOptions(null); - // Clear has custom configuration. - _section.hasCustomOptions(false); - // Clear column mappings. - _section.customDef.columnsMapping(null); - _section.columnsToMap(null); - }); - await _section.saveCustomDef(); - } + 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); - }); - // From the start desired access level is the same as current one. - this._desiredAccess = fromKo(_section.desiredAccessLevel); + private readonly _requiresAccess = Computed.create(this, use => { + const [currentAccess, desiredAccess] = [use(this._currentAccess), use(this._desiredAccess)]; + return desiredAccess && !isSatisfied(currentAccess, desiredAccess); + }); + + private readonly _widgetDetailsExpanded: Observable; + + private readonly _widgets: Observable = 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; - } - 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`); - } - } - // Options for access level. - const levels: IOptionFull[] = [ - {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}, + public buildDom(): DomContents { + return dom('div', + this._buildWidgetSelector(), + this._buildAccessLevelConfig(), + this._customSectionConfigurationConfig.buildDom(), + ); + } + + protected shouldRenderWidgetSelector(): boolean { + return true; + } + + protected async _getWidgets() { + return await this._gristDoc.app.topAppModel.getWidgets(); + } + + 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, - }), - testId('select') - ) - : null, - dom.maybe((use) => use(isCustom) && this.shouldRenderWidgetSelector(), () => [ + } + + 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()); + }), + ); + } + + 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() === '')); - }, - }) - ), - ]), - dom.maybe(prompt, () => - kf.prompt( - {tabindex: '-1'}, - cssColumns( - cssWarningWrapper(icon('Lock')), - dom( - 'div', - cssConfirmRow( - dom.domComputed(this._desiredAccess, (level) => buildPrompt(level)) + ); + } 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'), + ), + 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', + cssConfirmRow( + dom.domComputed(this._desiredAccess, (level) => this._buildAccessLevelPrompt(level)) + ), + cssConfirmRow( + primaryButton( + t('Accept'), + testId('access-accept'), + dom.on('click', () => this._grantDesiredAccess()) ), - cssConfirmRow( - primaryButton( - 'Accept', - testId('access-accept'), - dom.on('click', () => this._accept()) - ), - basicButton( - 'Reject', - testId('access-reject'), - dom.on('click', () => this._reject()) - ) + basicButton( + t('Reject'), + testId('access-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[] { + 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; +`); diff --git a/app/client/ui/CustomWidgetGallery.ts b/app/client/ui/CustomWidgetGallery.ts new file mode 100644 index 00000000..2f22de18 --- /dev/null +++ b/app/client/ui/CustomWidgetGallery.ts @@ -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; + private readonly _filteredWidgets = Observable.create(this, null); + private readonly _section: ViewSectionRec | null = null; + private readonly _searchText = Observable.create(this, ''); + private readonly _saveDisabled: Computed; + private readonly _savedWidgetId: Computed; + private readonly _selectedWidgetId = Observable.create(this, null); + private readonly _widgets = Observable.create(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}; +`); diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index e212987d..b3f0c845 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -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'), diff --git a/app/client/ui/PredefinedCustomSectionConfig.ts b/app/client/ui/PredefinedCustomSectionConfig.ts index b31bbdc4..1b690f08 100644 --- a/app/client/ui/PredefinedCustomSectionConfig.ts +++ b/app/client/ui/PredefinedCustomSectionConfig.ts @@ -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 { - // Do nothing. + protected async _getWidgets(): Promise { + return []; } } diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index a0f90bb5..f94dbaba 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -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, { - buttonLabel: t("Save"), - value: () => toPageWidget(section.peek()), - selectBy: (val) => gristDoc.selectBy(val), - }); }; + 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(activeSection.peek()), + selectBy: (val) => gristDoc.selectBy(val), + }); + }; } // Returns dom for a section item. diff --git a/app/client/ui/shadowScroll.ts b/app/client/ui/shadowScroll.ts index 3610cff6..89587234 100644 --- a/app/client/ui/shadowScroll.ts +++ b/app/client/ui/shadowScroll.ts @@ -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', ` diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index d58f39d6..39bbf15d 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -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", diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 5cd706e5..dcac5402 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -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'); diff --git a/app/common/CustomWidget.ts b/app/common/CustomWidget.ts index dc091e16..3bb70164 100644 --- a/app/common/CustomWidget.ts +++ b/app/common/CustomWidget.ts @@ -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; } /** diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts index 0ca63d0f..f7863924 100644 --- a/app/common/Prefs.ts +++ b/app/common/Prefs.ts @@ -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; diff --git a/app/common/ThemePrefs-ti.ts b/app/common/ThemePrefs-ti.ts index 4a726118..09d4add9 100644 --- a/app/common/ThemePrefs-ti.ts +++ b/app/common/ThemePrefs-ti.ts @@ -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 = { diff --git a/app/common/ThemePrefs.ts b/app/common/ThemePrefs.ts index 695299fc..03f45784 100644 --- a/app/common/ThemePrefs.ts +++ b/app/common/ThemePrefs.ts @@ -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; diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 6edc6fa8..e8410e1d 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -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. diff --git a/app/common/themes/GristDark.ts b/app/common/themes/GristDark.ts index fcde2bc9..5c64906a 100644 --- a/app/common/themes/GristDark.ts +++ b/app/common/themes/GristDark.ts @@ -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', }; diff --git a/app/common/themes/GristLight.ts b/app/common/themes/GristLight.ts index e871e957..60d1193c 100644 --- a/app/common/themes/GristLight.ts +++ b/app/common/themes/GristLight.ts @@ -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', }; diff --git a/static/icons/icons.css b/static/icons/icons.css index 5159b32d..f4851513 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -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=='); diff --git a/static/ui-icons/UI/Question.svg b/static/ui-icons/UI/Question.svg new file mode 100644 index 00000000..5433502a --- /dev/null +++ b/static/ui-icons/UI/Question.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test/nbrowser/AttachedCustomWidget.ts b/test/nbrowser/AttachedCustomWidget.ts index 34f2e7fe..3115edcb 100644 --- a/test/nbrowser/AttachedCustomWidget.ts +++ b/test/nbrowser/AttachedCustomWidget.ts @@ -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 () { diff --git a/test/nbrowser/BehavioralPrompts.ts b/test/nbrowser/BehavioralPrompts.ts index b8bda15a..a49b44da 100644 --- a/test/nbrowser/BehavioralPrompts.ts +++ b/test/nbrowser/BehavioralPrompts.ts @@ -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}); diff --git a/test/nbrowser/CustomView.ts b/test/nbrowser/CustomView.ts index ed32f62e..89de53fe 100644 --- a/test/nbrowser/CustomView.ts +++ b/test/nbrowser/CustomView.ts @@ -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 () => { - const parsed = await getData(12); - assert.deepEqual(parsed, expected); + await gu.waitToPass(async () => { + const parsed = await getData(12); + assert.deepEqual(parsed, expected); + }, 1000); }); // Change the access level away from 'full'. diff --git a/test/nbrowser/CustomWidgets.ts b/test/nbrowser/CustomWidgets.ts index 4730631d..bcd4aeaa 100644 --- a/test/nbrowser/CustomWidgets.ts +++ b/test/nbrowser/CustomWidgets.ts @@ -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. + afterEach(() => gu.checkForErrors()); - // Selectbox should have select label. - assert.equal(await current(), CUSTOM_URL); - - // 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 filter widgets on search', async () => { + await driver.find('.test-custom-widget-gallery-search').click(); + await gu.sendKeys('Custom'); + await gu.waitToPass(async () => { + assert.deepEqual( + await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()), + ['Custom URL'] + ); + }, 200); + await gu.sendKeys(await gu.selectAllKey(), Key.DELETE); + await gu.waitToPass(async () => { + assert.deepEqual( + await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()), + ['Custom URL', 'full', 'none', 'read table', 'W1', 'W2'] + ); + }, 200); + await gu.sendKeys('W'); + await gu.waitToPass(async () => { + assert.deepEqual( + await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()), + ['Custom URL', 'W1', 'W2'] + ); + }, 200); + await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'tab'); + await gu.waitToPass(async () => { + assert.deepEqual( + await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()), + ['read table'] + ); + }, 200); + await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'Markdown'); + await gu.waitToPass(async () => { + assert.deepEqual( + await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()), + [] + ); + }, 200); + await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'Developer 1'); + await gu.waitToPass(async () => { + assert.deepEqual( + await driver.findAll('.test-custom-widget-gallery-widget-name', (el) => el.getText()), + ['W1'] + ); + }, 200); + }); - it('should offer only custom url when disabled', async () => { - await toggle(); - await select(CUSTOM_URL); + 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'); }); diff --git a/test/nbrowser/CustomWidgetsConfig.ts b/test/nbrowser/CustomWidgetsConfig.ts index 7222fc97..f9f5e6d0 100644 --- a/test/nbrowser/CustomWidgetsConfig.ts +++ b/test/nbrowser/CustomWidgetsConfig.ts @@ -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); } }; diff --git a/test/nbrowser/LinkingBidirectional.ts b/test/nbrowser/LinkingBidirectional.ts index 7064f7da..8f1baaa6 100644 --- a/test/nbrowser/LinkingBidirectional.ts +++ b/test/nbrowser/LinkingBidirectional.ts @@ -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. diff --git a/test/nbrowser/RightPanel.ts b/test/nbrowser/RightPanel.ts index a2f4af4b..a7877b1a 100644 --- a/test/nbrowser/RightPanel.ts +++ b/test/nbrowser/RightPanel.ts @@ -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(); diff --git a/test/nbrowser/SelectBy.ts b/test/nbrowser/SelectBy.ts index 8fd90d3b..cb2df6db 100644 --- a/test/nbrowser/SelectBy.ts +++ b/test/nbrowser/SelectBy.ts @@ -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(); diff --git a/test/nbrowser/ViewLayoutCollapse.ts b/test/nbrowser/ViewLayoutCollapse.ts index 3c522373..13a3ee98 100644 --- a/test/nbrowser/ViewLayoutCollapse.ts +++ b/test/nbrowser/ViewLayoutCollapse.ts @@ -100,15 +100,15 @@ describe("ViewLayoutCollapse", function() { // Add custom section. await gu.addNewPage('Table', 'Companies'); - await gu.addNewSection('Custom', 'Companies', { selectBy: 'COMPANIES'}); + await gu.addNewSection('Custom', 'Companies', {selectBy: 'COMPANIES'}); // Serve custom widget. const widgetServer = await serveSomething(app => { 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. @@ -139,15 +139,15 @@ describe("ViewLayoutCollapse", function() { // Add custom section. await gu.addNewPage('Table', 'Companies'); - await gu.addNewSection('Custom', 'Companies', { selectBy: 'COMPANIES'}); + await gu.addNewSection('Custom', 'Companies', {selectBy: 'COMPANIES'}); // Serve custom widget. const widgetServer = await serveSomething(app => { 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. diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 6fc26dd6..25052905 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -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'; /** diff --git a/test/nbrowser/gristWebDriverUtils.ts b/test/nbrowser/gristWebDriverUtils.ts index a017e30d..9323b2c4 100644 --- a/test/nbrowser/gristWebDriverUtils.ts +++ b/test/nbrowser/gristWebDriverUtils.ts @@ -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; }