From 42d7e31d273d7d0b1b547b265bbfa96e0013a04b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Thu, 22 Feb 2024 18:09:39 +0100 Subject: [PATCH] (core) In custom widgets show placeholder content until all columns are mapped Summary: Showing configuration screen when widget is not mapped Test Plan: New test added Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4192 --- app/client/components/CustomView.css | 32 ++ app/client/components/CustomView.ts | 38 ++- app/client/components/WidgetFrame.ts | 61 ++-- static/img/empty-widget.svg | 128 ++++++++ test/nbrowser/AttachedCustomWidget.ts | 6 +- test/nbrowser/CustomWidgetsConfig.ts | 395 ++++++++++++++----------- test/nbrowser/GridViewNewColumnMenu.ts | 2 + test/nbrowser/gristUtils.ts | 2 +- 8 files changed, 459 insertions(+), 205 deletions(-) create mode 100644 static/img/empty-widget.svg diff --git a/app/client/components/CustomView.css b/app/client/components/CustomView.css index 15e4c04b..64c5d0a7 100644 --- a/app/client/components/CustomView.css +++ b/app/client/components/CustomView.css @@ -8,3 +8,35 @@ iframe.custom_view { padding: 15px; margin: 15px; } + +.custom_view_no_mapping { + padding: 15px; + margin: 15px; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + color: var(--grist-theme-text, var(--grist-color-dark)); +} + +.custom_view_no_mapping h1 { + max-width: 310px; + margin-bottom: 24px; + margin-top: 56px; + + font-style: normal; + font-weight: 600; + font-size: 22px; + line-height: 26px; + text-align: center; + text-wrap: balance; +} + +.custom_view_no_mapping p { + max-width: 310px; + font-style: normal; + font-weight: 400; + font-size: 13px; + line-height: 16px; + text-align: center; +} diff --git a/app/client/components/CustomView.ts b/app/client/components/CustomView.ts index 7cfe0aa7..8c05db3c 100644 --- a/app/client/components/CustomView.ts +++ b/app/client/components/CustomView.ts @@ -16,8 +16,10 @@ import { WidgetFrame } from 'app/client/components/WidgetFrame'; import {CustomSectionElement, ViewProcess} from 'app/client/lib/CustomSectionElement'; +import {makeT} from 'app/client/lib/localization'; import {Disposable} from 'app/client/lib/dispose'; import dom from 'app/client/lib/dom'; +import {makeTestId} from 'app/client/lib/domUtils'; import * as kd from 'app/client/lib/koDom'; import DataTableModel from 'app/client/models/DataTableModel'; import {ViewSectionRec} from 'app/client/models/DocModel'; @@ -28,12 +30,14 @@ import {closeRegisteredMenu} from 'app/client/ui2018/menus'; import {AccessLevel} from 'app/common/CustomWidget'; import {defaultLocale} from 'app/common/gutil'; import {PluginInstance} from 'app/common/PluginInstance'; -import {getGristConfig} from 'app/common/urlUtils'; import {Events as BackboneEvents} from 'backbone'; import {dom as grains} from 'grainjs'; import * as ko from 'knockout'; import defaults = require('lodash/defaults'); +const t = makeT('CustomView'); +const testId = makeTestId('test-custom-widget-'); + /** * * Built in settings for a custom widget. Used when the custom @@ -104,6 +108,7 @@ export class CustomView extends Disposable { private _pluginInstance: PluginInstance|undefined; private _frame: WidgetFrame; // plugin frame (holding external page) + private _hasUnmappedColumns: ko.Computed; public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { BaseView.call(this as any, gristDoc, viewSectionModel, { 'addNewRow': true }); @@ -124,6 +129,15 @@ export class CustomView extends Disposable { this.autoDispose(this.customDef.sectionId.subscribe(this._updateCustomSection, this)); this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.hasFocus)); + this._hasUnmappedColumns = this.autoDispose(ko.pureComputed(() => { + const columns = this.viewSection.columnsToMap(); + if (!columns) { return false; } + const required = columns.filter(col => typeof col === 'string' || !(col.optional === true)) + .map(col => typeof col === 'string' ? col : col.name); + const mapped = this.viewSection.mappedColumns() || {}; + return required.some(col => !mapped[col]) && this.customDef.mode() === "url"; + })); + this.viewPane = this.autoDispose(this._buildDom()); this._updatePluginInstance(); } @@ -138,10 +152,6 @@ export class CustomView extends Disposable { return {}; } - protected getEmptyWidgetPage(): string { - return new URL("custom-widget.html", getGristConfig().homeUrl!).href; - } - /** * Find a plugin instance that matches the plugin id, update the `found` observables, then tries to * find a matching section. @@ -207,11 +217,21 @@ export class CustomView extends Disposable { dom.autoDispose(showPluginNotification), dom.autoDispose(showSectionNotification), dom.autoDispose(showPluginContent), + + kd.maybe(this._hasUnmappedColumns, () => dom('div.custom_view_no_mapping', + testId('not-mapped'), + dom('img', {src: 'img/empty-widget.svg'}), + dom('h1', kd.text(t("Some required columns aren't mapped"))), + dom('p', + t('To use this widget, please map all non-optional columns from the creator panel on the right.') + ), + )), // todo: should display content in webview when running electron // prefer widgetId; spelunk in widgetDef for older docs - kd.scope(() => [mode(), url(), access(), widgetId() || widgetDef()?.widgetId || '', pluginId()], - ([_mode, _url, _access, _widgetId, _pluginId]: string[]) => - _mode === "url" ? + kd.scope(() => [ + this._hasUnmappedColumns(), mode(), url(), access(), widgetId() || widgetDef()?.widgetId || '', pluginId() + ], ([_hide, _mode, _url, _access, _widgetId, _pluginId]: string[]) => + _mode === "url" && !_hide ? this._buildIFrame({ baseUrl: _url, access: builtInSettings.accessLevel || (_access as AccessLevel || AccessLevel.none), @@ -254,7 +274,7 @@ export class CustomView extends Disposable { const documentSettings = this.gristDoc.docData.docSettings(); const readonly = this.gristDoc.isReadonly.get(); const widgetFrame = WidgetFrame.create(null, { - url: baseUrl || this.getEmptyWidgetPage(), + url: baseUrl, widgetId, pluginId, access, diff --git a/app/client/components/WidgetFrame.ts b/app/client/components/WidgetFrame.ts index 0730ed6f..bc6228cb 100644 --- a/app/client/components/WidgetFrame.ts +++ b/app/client/components/WidgetFrame.ts @@ -12,6 +12,7 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions'; import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes'; import {Theme} from 'app/common/ThemePrefs'; +import {getGristConfig} from 'app/common/urlUtils'; import { AccessTokenOptions, CursorPos, CustomSectionAPI, FetchSelectedOptions, GristDocAPI, GristView, InteractionOptionsRequest, WidgetAPI, WidgetColumnMap @@ -45,7 +46,7 @@ export interface WidgetFrameOptions { /** * Url of external page. Iframe is rebuild each time the URL changes. */ - url: string; + url: string|null; /** * ID of widget, if known. When set, the url for the specified widget * in the WidgetRepository, if found, will take precedence. @@ -102,6 +103,12 @@ export class WidgetFrame extends DisposableWithEvents { private _visible = Observable.create(this, !this._options.showAfterReady); private readonly _widget = Observable.create(this, null); + private _url: Observable; + /** + * If the widget URL is empty, it also means that we are showing the empty page. + */ + private _isEmpty: Observable; + constructor(private _options: WidgetFrameOptions) { super(); _options.access = _options.access || AccessLevel.none; @@ -129,6 +136,22 @@ export class WidgetFrame extends DisposableWithEvents { _options.configure?.(this); this._checkWidgetRepository().catch(reportError); + + // Url if set. + const maybeUrl = Computed.create(this, use => use(this._widget)?.url || this._options.url); + + // Url to widget or empty page with access level and preferences. + this._url = Computed.create(this, use => this._urlWithAccess(use(maybeUrl) || this._getEmptyWidgetPage())); + + // Iframe is empty when url is not set. + this._isEmpty = Computed.create(this, use => !use(maybeUrl)); + + // When isEmpty is switched to true, reset the ready state. + this.autoDispose(this._isEmpty.addListener(isEmpty => { + if (isEmpty) { + this._readyCalled.set(false); + } + })); } /** @@ -190,30 +213,30 @@ export class WidgetFrame extends DisposableWithEvents { dom.style('visibility', use => use(this._visible) ? 'visible' : 'hidden'), dom.cls('clipboard_focus'), dom.cls('custom_view'), - dom.attr('src', use => this._getUrl(use(this._widget))), + dom.attr('src', this._url), hooks.iframeAttributes, - testId('ready', this._readyCalled), + testId('ready', use => use(this._readyCalled) && !use(this._isEmpty)), self => void onElem(self), ); return this._iframe; } - private _getUrl(widget: ICustomWidget|null): string { - // Append access level to query string. - const urlWithAccess = (url: string) => { - if (!url) { - 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. - const settingsParams = new URLSearchParams(this._options.preferences); - settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value)); - return urlObj.href; - }; - const url = widget?.url || this._options.url || 'about:blank'; - return urlWithAccess(url); + // Appends access level to query string. + private _urlWithAccess(url: string) { + if (!url) { + 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. + const settingsParams = new URLSearchParams(this._options.preferences); + settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value)); + return urlObj.href; + } + + private _getEmptyWidgetPage(): string { + return new URL("custom-widget.html", getGristConfig().homeUrl!).href; } private _onMessage(event: MessageEvent) { diff --git a/static/img/empty-widget.svg b/static/img/empty-widget.svg new file mode 100644 index 00000000..bd622ad4 --- /dev/null +++ b/static/img/empty-widget.svg @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/nbrowser/AttachedCustomWidget.ts b/test/nbrowser/AttachedCustomWidget.ts index 2f5d244c..34f2e7fe 100644 --- a/test/nbrowser/AttachedCustomWidget.ts +++ b/test/nbrowser/AttachedCustomWidget.ts @@ -40,9 +40,9 @@ describe('AttachedCustomWidget', function () { .header('Content-Type', 'text/html') .send('\n' + (req.query.name || req.query.access) + // send back widget name from query string or access level - ''+ - ""+ + '' + + "" + '\n') .end() ); diff --git a/test/nbrowser/CustomWidgetsConfig.ts b/test/nbrowser/CustomWidgetsConfig.ts index 44274cab..7222fc97 100644 --- a/test/nbrowser/CustomWidgetsConfig.ts +++ b/test/nbrowser/CustomWidgetsConfig.ts @@ -1,4 +1,4 @@ -import {assert, driver, Key} from 'mocha-webdriver'; +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'; @@ -15,6 +15,7 @@ const NORMAL_WIDGET = 'Normal'; const READ_WIDGET = 'Read'; const FULL_WIDGET = 'Full'; const COLUMN_WIDGET = 'COLUMN_WIDGET'; +const REQUIRED_WIDGET = 'REQUIRED_WIDGET'; // Custom URL label in selectbox. const CUSTOM_URL = 'Custom URL'; // Holds url for sample widget server. @@ -129,6 +130,9 @@ describe('CustomWidgetsConfig', function () { let mainSession: gu.Session; gu.bigScreen(); + + addToRepl('getOptions', getOptions); + before(async function () { if (server.isExternalServer()) { this.skip(); @@ -164,9 +168,15 @@ describe('CustomWidgetsConfig', function () { { // Widget with column mapping name: COLUMN_WIDGET, - url: createConfigUrl({requiredAccess: AccessLevel.read_table, columns: ['Column']}), + url: createConfigUrl({requiredAccess: AccessLevel.read_table, columns: [{name:'Column', optional: true}]}), widgetId: 'tester5', }, + { + // Widget with required column mapping + name: REQUIRED_WIDGET, + url: createConfigUrl({requiredAccess: AccessLevel.read_table, columns: [{name:'Column', optional: false}]}), + widgetId: 'tester6', + }, ]); }); addStatic(app); @@ -188,146 +198,6 @@ describe('CustomWidgetsConfig', function () { await server.testingHooks.setWidgetRepositoryUrl(''); }); - // Poor man widget rpc. Class that invokes various parts in the tester widget. - class Widget { - constructor() {} - // Wait for a frame. - public async waitForFrame() { - await driver.findWait(`iframe.test-custom-widget-ready`, 1000); - await driver.wait(async () => await driver.find('iframe').isDisplayed(), 1000); - await widget.waitForPendingRequests(); - } - public async waitForPendingRequests() { - await this._inWidgetIframe(async () => { - await driver.executeScript('grist.testWaitForPendingRequests();'); - }); - } - public async content() { - return await this._read('body'); - } - public async readonly() { - const text = await this._read('#readonly'); - return text === 'true'; - } - public async access() { - const text = await this._read('#access'); - return text as AccessLevel; - } - public async onRecordMappings() { - const text = await this._read('#onRecordMappings'); - return JSON.parse(text || 'null'); - } - public async onRecords() { - const text = await this._read('#onRecords'); - return JSON.parse(text || 'null'); - } - public async onRecord() { - const text = await this._read('#onRecord'); - return JSON.parse(text || 'null'); - } - public async onRecordsMappings() { - const text = await this._read('#onRecordsMappings'); - return JSON.parse(text || 'null'); - } - public async log() { - const text = await this._read('#log'); - return text || ''; - } - // Wait for frame to close. - public async waitForClose() { - await driver.wait(async () => !(await driver.find('iframe').isPresent()), 3000); - } - // Wait for the onOptions event, and return its value. - public async onOptions() { - const text = await this._inWidgetIframe(async () => { - // Wait for options to get filled, initially this div is empty, - // as first message it should get at least null as an options. - await driver.wait(async () => await driver.find('#onOptions').getText(), 3000); - return await driver.find('#onOptions').getText(); - }); - return JSON.parse(text); - } - public async wasConfigureCalled() { - const text = await this._read('#configure'); - return text === 'called'; - } - public async setOptions(options: any) { - return await this.invokeOnWidget('setOptions', [options]); - } - public async setOption(key: string, value: any) { - return await this.invokeOnWidget('setOption', [key, value]); - } - public async getOption(key: string) { - return await this.invokeOnWidget('getOption', [key]); - } - public async clearOptions() { - return await this.invokeOnWidget('clearOptions'); - } - public async getOptions() { - return await this.invokeOnWidget('getOptions'); - } - public async mappings() { - return await this.invokeOnWidget('mappings'); - } - public async clearLog() { - return await this.invokeOnWidget('clearLog'); - } - // Invoke method on a Custom Widget. - // Each method is available as a button with content that is equal to the method name. - // It accepts single argument, that we pass by serializing it to #input textbox. Widget invokes - // the method and serializes its return value to #output div. When there is an error, it is also - // serialized to the #output div. - public async invokeOnWidget(name: string, input?: any[]) { - // Switch to frame. - const iframe = driver.find('iframe'); - await driver.switchTo().frame(iframe); - // Clear input box that holds arguments. - await driver.find('#input').click(); - await gu.clearInput(); - // Serialize argument to the textbox (or leave empty). - if (input !== undefined) { - await driver.sendKeys(JSON.stringify(input)); - } - // Find button that is responsible for invoking method. - await driver.findContent('button', gu.exactMatch(name)).click(); - // Wait for the #output div to be filled with a result. Custom Widget will set it to - // "waiting..." before invoking the method. - await driver.wait(async () => (await driver.find('#output').value()) !== 'waiting...'); - // Read the result. - const text = await driver.find('#output').getText(); - // Switch back to main window. - await driver.switchTo().defaultContent(); - // If the method was a void method, the output will be "undefined". - if (text === 'undefined') { - return; // Simulate void method. - } - // Result will always be parsed json. - const parsed = JSON.parse(text); - // All exceptions will be serialized to { error : <> } - if (parsed?.error) { - // Rethrow the error. - throw new Error(parsed.error); - } else { - // Or return result. - return parsed; - } - } - - private async _read(selector: string) { - return this._inWidgetIframe(() => driver.find(selector).getText()); - } - - private async _inWidgetIframe(callback: () => Promise) { - const iframe = driver.find('iframe'); - await driver.switchTo().frame(iframe); - const retVal = await callback(); - await driver.switchTo().defaultContent(); - return retVal; - } - } - // Rpc for main widget (Custom Widget). - const widget = new Widget(); - beforeEach(async () => { // Before each test, we will switch to Custom Url (to cleanup the widget) // and then back to the Tester widget. @@ -337,6 +207,47 @@ describe('CustomWidgetsConfig', function () { } await toggleWidgetMenu(); await clickOption(TESTER_WIDGET); + await widget.waitForFrame(); + }); + + it('should hide widget when some columns are not mapped', async () => { + // Reset the widget to the one that has a column mapping requirements. + await widget.resetWidget(); + + // Since the widget was reset, we don't have .test-custom-widget-ready element. + 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.acceptAccessRequest(); + + // The widget iframe should be covered with a text explaining that the widget is not configured. + assert.isTrue(await driver.findWait('.test-custom-widget-not-mapped', 1000).isDisplayed()); + + // The content should at least have those words: + assert.include(await driver.find('.test-custom-widget-not-mapped').getText(), + "Some required columns aren't mapped"); + + // Make sure that the iframe is not displayed. + assert.isFalse(await driver.find('.test-custom-widget-ready').isPresent()); + + // Now map the column. + await toggleDrop(pickerDrop('Column')); + + // Map it to A. + await clickOption('A'); + + // Make sure that the text is gone. + await gu.waitToPass(async () => { + assert.isFalse(await driver.find('.test-config-widget-not-mapped').isPresent()); + }); + + // Make sure the widget is now visible. + assert.isTrue(await driver.find('.test-custom-widget-ready').isDisplayed()); + + // And we see widget with info about mapped columns, Column to A. + assert.deepEqual(await widget.onRecordsMappings(), {Column: 'A'}); }); it('should hide mappings when there is no good column', async () => { @@ -346,7 +257,7 @@ describe('CustomWidgetsConfig', function () { } await gu.setWidgetUrl( createConfigUrl({ - columns: [{name: 'M2', type: 'Date'}], + columns: [{name: 'M2', type: 'Date', optional: true}], requiredAccess: 'read table', }) ); @@ -382,7 +293,7 @@ describe('CustomWidgetsConfig', function () { // Now expand the drop again and make sure we can't clear it. await toggleDrop(pickerDrop('M2')); - assert.deepEqual(await getOptions(), ['NewCol']); + assert.deepEqual(await getOptions(), ['NewCol', 'Clear selection']); // Now remove the column, and make sure that the drop is disabled again. await driver.sendKeys(Key.ESCAPE); @@ -485,11 +396,8 @@ describe('CustomWidgetsConfig', function () { requiredAccess: 'read table', }) ); - await widget.waitForFrame(); await gu.acceptAccessRequest(); - await widget.waitForPendingRequests(); - // Mappings should be empty - assert.isNull(await widget.onRecordsMappings()); + await widget.waitForPlaceholder(); // We should see 4 pickers assert.isTrue(await driver.find(pickerLabel('M1')).isPresent()); assert.isTrue(await driver.find(pickerLabel('M2')).isPresent()); @@ -508,27 +416,20 @@ describe('CustomWidgetsConfig', function () { // Should be able to select column A for all options await toggleDrop(pickerDrop('M1')); await clickOption('A'); - await widget.waitForPendingRequests(); - const empty = {M1: null, M2: null, M3: null, M4: null}; - assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A'}); await toggleDrop(pickerDrop('M2')); await clickOption('A'); - await widget.waitForPendingRequests(); - assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A', M2: 'A'}); await toggleDrop(pickerDrop('M3')); await clickOption('A'); - await widget.waitForPendingRequests(); - assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A', M2: 'A', M3: 'A'}); await toggleDrop(pickerDrop('M4')); await clickOption('A'); + await widget.waitForFrame(); await widget.waitForPendingRequests(); assert.deepEqual(await widget.onRecordsMappings(), {M1: 'A', M2: 'A', M3: 'A', M4: 'A'}); // Single record should also receive update. assert.deepEqual(await widget.onRecordMappings(), {M1: 'A', M2: 'A', M3: 'A', M4: 'A'}); // Undo should revert mappings - there should be only 3 operations to revert to first mapping. await gu.undo(3); - await widget.waitForPendingRequests(); - assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A'}); + await widget.waitForPlaceholder(); // Add another columns, numeric B and any C. await gu.selectSectionByTitle('Table'); await gu.addColumn('B'); @@ -541,10 +442,6 @@ describe('CustomWidgetsConfig', function () { assert.deepEqual(await getOptions(), ['A', 'B', 'C']); await toggleDrop(pickerDrop('M4')); assert.deepEqual(await getOptions(), ['A', 'C']); - await toggleDrop(pickerDrop('M1')); - await clickOption('B'); - await widget.waitForPendingRequests(); - assert.deepEqual(await widget.onRecordsMappings(), {...empty, M1: 'B'}); await revert(); }); @@ -602,8 +499,8 @@ describe('CustomWidgetsConfig', function () { await gu.setWidgetUrl( createConfigUrl({ columns: [ - {name: 'M1', allowMultiple: true}, - {name: 'M2', type: 'Text', allowMultiple: true}, + {name: 'M1', allowMultiple: true, optional: true}, + {name: 'M2', type: 'Text', allowMultiple: true, optional: true}, ], requiredAccess: 'read table', }) @@ -686,8 +583,8 @@ describe('CustomWidgetsConfig', function () { await gu.setWidgetUrl( createConfigUrl({ columns: [ - {name: 'M1', type: 'Date,DateTime'}, - {name: 'M2', type: 'Date, DateTime ', allowMultiple: true}, + {name: 'M1', type: 'Date,DateTime', optional: true}, + {name: 'M2', type: 'Date, DateTime ', allowMultiple: true, optional: true}, ], requiredAccess: 'read table', }) @@ -747,10 +644,10 @@ describe('CustomWidgetsConfig', function () { await gu.setWidgetUrl( createConfigUrl({ columns: [ - {name: 'Any', type: 'Any', strictType: true}, - {name: 'Date_Numeric', type: 'Date, Numeric', strictType: true}, - {name: 'Date_Any', type: 'Date, Any', strictType: true}, - {name: 'Date', type: 'Date', strictType: true}, + {name: 'Any', type: 'Any', strictType: true, optional: true}, + {name: 'Date_Numeric', type: 'Date, Numeric', strictType: true, optional: true}, + {name: 'Date_Any', type: 'Date, Any', strictType: true, optional: true}, + {name: 'Date', type: 'Date', strictType: true, optional: true}, ], requiredAccess: 'read table', }) @@ -791,7 +688,7 @@ describe('CustomWidgetsConfig', function () { await gu.setWidgetUrl( createConfigUrl({ columns: [ - {name: 'Choice', type: 'Choice', strictType: true}, + {name: 'Choice', type: 'Choice', strictType: true, optional: true}, ], requiredAccess: 'read table', }) @@ -839,7 +736,7 @@ describe('CustomWidgetsConfig', function () { await clickOption(CUSTOM_URL); await gu.setWidgetUrl( createConfigUrl({ - columns: [{name: 'M1'}, {name: 'M2', allowMultiple: true}], + columns: [{name: 'M1', optional: true}, {name: 'M2', allowMultiple: true, optional: true}], requiredAccess: 'read table', }) ); @@ -905,7 +802,7 @@ describe('CustomWidgetsConfig', function () { // Add B column as a new one. await toggleDrop(pickerDrop('M1')); // Make sure it is there to select. - assert.deepEqual(await getOptions(), ['A', 'C', 'B']); + assert.deepEqual(await getOptions(), ['A', 'C', 'B', 'Clear selection']); await clickOption('B'); await widget.waitForPendingRequests(); await click(pickerAdd('M2')); @@ -928,7 +825,10 @@ describe('CustomWidgetsConfig', function () { await clickOption(CUSTOM_URL); await gu.setWidgetUrl( createConfigUrl({ - columns: [{name: 'M1', type: 'Text'}, {name: 'M2', type: 'Text', allowMultiple: true}], + columns: [ + {name: 'M1', type: 'Text', optional: true}, + {name: 'M2', type: 'Text', allowMultiple: true, optional: true} + ], requiredAccess: 'read table', }) ); @@ -1220,3 +1120,152 @@ describe('CustomWidgetsConfig', function () { await refresh(); }); }); + +// Poor man widget rpc. Class that invokes various parts in the tester widget. +const widget = { + async waitForPlaceholder() { + assert.isTrue(await driver.findWait('.test-custom-widget-not-mapped', 1000).isDisplayed()); + }, + // Wait for a frame. + async waitForFrame() { + await driver.findWait(`iframe.test-custom-widget-ready`, 1000); + await driver.wait(async () => await driver.find('iframe').isDisplayed(), 1000); + await widget.waitForPendingRequests(); + }, + async waitForPendingRequests() { + await this._inWidgetIframe(async () => { + await driver.executeScript('grist.testWaitForPendingRequests();'); + }); + }, + async content() { + return await this._read('body'); + }, + async readonly() { + const text = await this._read('#readonly'); + return text === 'true'; + }, + async access() { + const text = await this._read('#access'); + return text as AccessLevel; + }, + async onRecordMappings() { + const text = await this._read('#onRecordMappings'); + return JSON.parse(text || 'null'); + }, + async onRecords() { + const text = await this._read('#onRecords'); + return JSON.parse(text || 'null'); + }, + async onRecord() { + const text = await this._read('#onRecord'); + return JSON.parse(text || 'null'); + }, + /** + * Reads last mapping parameter received by the widget as part of onRecords call. + */ + async onRecordsMappings() { + const text = await this._read('#onRecordsMappings'); + return JSON.parse(text || 'null'); + }, + async log() { + const text = await this._read('#log'); + return text || ''; + }, + // Wait for frame to close. + async waitForClose() { + await driver.wait(async () => !(await driver.find('iframe').isPresent()), 3000); + }, + // Wait for the onOptions event, and return its value. + async onOptions() { + const text = await this._inWidgetIframe(async () => { + // Wait for options to get filled, initially this div is empty, + // as first message it should get at least null as an options. + await driver.wait(async () => await driver.find('#onOptions').getText(), 3000); + return await driver.find('#onOptions').getText(); + }); + return JSON.parse(text); + }, + async wasConfigureCalled() { + const text = await this._read('#configure'); + return text === 'called'; + }, + async setOptions(options: any) { + return await this.invokeOnWidget('setOptions', [options]); + }, + async setOption(key: string, value: any) { + return await this.invokeOnWidget('setOption', [key, value]); + }, + async getOption(key: string) { + return await this.invokeOnWidget('getOption', [key]); + }, + async clearOptions() { + return await this.invokeOnWidget('clearOptions'); + }, + async getOptions() { + return await this.invokeOnWidget('getOptions'); + }, + async mappings() { + return await this.invokeOnWidget('mappings'); + }, + async clearLog() { + return await this.invokeOnWidget('clearLog'); + }, + // Invoke method on a Custom Widget. + // Each method is available as a button with content that is equal to the method name. + // It accepts single argument, that we pass by serializing it to #input textbox. Widget invokes + // the method and serializes its return value to #output div. When there is an error, it is also + // serialized to the #output div. + async invokeOnWidget(name: string, input?: any[]) { + // Switch to frame. + const iframe = driver.find('iframe'); + await driver.switchTo().frame(iframe); + // Clear input box that holds arguments. + await driver.find('#input').click(); + await gu.clearInput(); + // Serialize argument to the textbox (or leave empty). + if (input !== undefined) { + await driver.sendKeys(JSON.stringify(input)); + } + // Find button that is responsible for invoking method. + await driver.findContent('button', gu.exactMatch(name)).click(); + // Wait for the #output div to be filled with a result. Custom Widget will set it to + // "waiting..." before invoking the method. + await driver.wait(async () => (await driver.find('#output').value()) !== 'waiting...'); + // Read the result. + const text = await driver.find('#output').getText(); + // Switch back to main window. + await driver.switchTo().defaultContent(); + // If the method was a void method, the output will be "undefined". + if (text === 'undefined') { + return; // Simulate void method. + } + // Result will always be parsed json. + const parsed = JSON.parse(text); + // All exceptions will be serialized to { error : <> } + if (parsed?.error) { + // Rethrow the error. + throw new Error(parsed.error); + } else { + // Or return result. + return parsed; + } + }, + async _read(selector: string) { + return this._inWidgetIframe(() => driver.find(selector).getText()); + }, + async _inWidgetIframe(callback: () => Promise) { + const iframe = driver.find('iframe'); + await driver.switchTo().frame(iframe); + const retVal = await callback(); + await driver.switchTo().defaultContent(); + return retVal; + }, + /** + * Resets the widget by first selecting Custom URL option from the menu, which clearOptions + * any existing widget state (even if the Custom URL was already selected). + */ + async resetWidget() { + await toggleWidgetMenu(); + await clickOption(CUSTOM_URL); + } +}; diff --git a/test/nbrowser/GridViewNewColumnMenu.ts b/test/nbrowser/GridViewNewColumnMenu.ts index e3773d6f..17f6bd1f 100644 --- a/test/nbrowser/GridViewNewColumnMenu.ts +++ b/test/nbrowser/GridViewNewColumnMenu.ts @@ -259,6 +259,8 @@ describe('GridViewNewColumnMenu', function () { await gu.waitForServer(); //discard rename menu await driver.findWait('.test-column-title-close', STANDARD_WAITING_TIME).click(); + // Wait for the sidepanel animation. + await gu.waitForSidePanel(); //check if right menu is opened on column section assert.isTrue(await driver.findWait('.test-right-tab-field', 1000).isDisplayed()); await gu.toggleSidePanel("right", "close"); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 93d570be..52b305af 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -3393,7 +3393,7 @@ export async function hasAccessPrompt() { * Accepts new access level. */ export async function acceptAccessRequest() { - await driver.find('.test-config-widget-access-accept').click(); + await driver.findWait('.test-config-widget-access-accept', 1000).click(); } /**