diff --git a/.gitignore b/.gitignore
index 8b622f6a..af1f4bfc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
/_build/
/static/*.bundle.js
/static/*.bundle.js.map
+/static/grist-plugin-api*
/static/bundle.css
/static/browser-check.js
/static/*.bundle.js.*.txt
diff --git a/buildtools/build.sh b/buildtools/build.sh
index b3bb832a..38c1b69d 100755
--- a/buildtools/build.sh
+++ b/buildtools/build.sh
@@ -10,6 +10,8 @@ fi
set -x
tsc --build $PROJECT
+buildtools/update_type_info.sh app
webpack --config buildtools/webpack.config.js --mode production
webpack --config buildtools/webpack.check.js --mode production
+webpack --config buildtools/webpack.api.config.js --mode production
cat app/client/*.css app/client/*/*.css > static/bundle.css
diff --git a/buildtools/update_type_info.sh b/buildtools/update_type_info.sh
new file mode 100755
index 00000000..2e90b1dc
--- /dev/null
+++ b/buildtools/update_type_info.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+set -e
+
+# updates any Foo*-ti.ts files $root that are older than Foo.ts
+
+root=$1
+if [[ -z "$root" ]]; then
+ echo "Usage: $0 app"
+ exit 1
+fi
+
+for root in "$@"; do
+ for ti in $(find $root/ -iname "*-ti.ts"); do
+ root=$(basename $ti -ti.ts)
+ dir=$(dirname $ti)
+ src="$dir/$root.ts"
+ if [ ! -e $src ]; then
+ echo "Cannot find src $src for $ti, aborting"
+ exit 1
+ fi
+ if [ $src -nt $ti ]; then
+ echo "Updating $ti from $src"
+ node_modules/.bin/ts-interface-builder $src
+ fi
+ done
+done
diff --git a/buildtools/webpack.api.config.js b/buildtools/webpack.api.config.js
new file mode 100644
index 00000000..e14f973c
--- /dev/null
+++ b/buildtools/webpack.api.config.js
@@ -0,0 +1,44 @@
+const path = require('path');
+
+module.exports = {
+ target: 'web',
+ entry: {
+ "grist-plugin-api": "app/plugin/grist-plugin-api",
+ },
+ output: {
+ sourceMapFilename: "[file].map",
+ path: path.resolve("./static"),
+ library: "grist"
+ },
+ devtool: "source-map",
+ node: false,
+ resolve: {
+ extensions: ['.ts', '.js'],
+ modules: [
+ path.resolve('.'),
+ path.resolve('./ext'),
+ path.resolve('./stubs'),
+ path.resolve('./node_modules')
+ ],
+ fallback: {
+ 'path': require.resolve("path-browserify"),
+ },
+ },
+ optimization: {
+ minimize: false, // keep class names in code
+ },
+ module: {
+ rules: [
+ {
+ test: /\.(js|ts)?$/,
+ loader: 'esbuild-loader',
+ options: {
+ loader: 'ts',
+ target: 'es2017',
+ sourcemap: true,
+ },
+ exclude: /node_modules/
+ },
+ ]
+ }
+};
diff --git a/package.json b/package.json
index c0fd673d..2d7a7adf 100644
--- a/package.json
+++ b/package.json
@@ -81,6 +81,7 @@
"sinon": "7.1.1",
"source-map-loader": "^0.2.4",
"tmp-promise": "1.0.5",
+ "ts-interface-builder": "0.3.2",
"typescript": "4.7.4",
"webpack": "5.73.0",
"webpack-cli": "4.10.0",
diff --git a/test/fixtures/docs/CustomWidget.grist b/test/fixtures/docs/CustomWidget.grist
new file mode 100644
index 00000000..ec7e322f
Binary files /dev/null and b/test/fixtures/docs/CustomWidget.grist differ
diff --git a/test/fixtures/docs/TypeEncoding.grist b/test/fixtures/docs/TypeEncoding.grist
new file mode 100644
index 00000000..0263caaf
Binary files /dev/null and b/test/fixtures/docs/TypeEncoding.grist differ
diff --git a/test/fixtures/sites/config/index.html b/test/fixtures/sites/config/index.html
new file mode 100644
index 00000000..2c3cb8fa
--- /dev/null
+++ b/test/fixtures/sites/config/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+ onOptions event data:
+
+
+ onRecord event data:
+
+
+ onRecord mapping data:
+
+
+ onRecords event data:
+
+
+ onRecord mappings data:
+
+
+ configure handler:
+
+
+ Method input json:
+
+ Method output json:
+
+ Methods:
+
+
+
+
+
+
+
+
+
diff --git a/test/fixtures/sites/config/page.js b/test/fixtures/sites/config/page.js
new file mode 100644
index 00000000..37bcc366
--- /dev/null
+++ b/test/fixtures/sites/config/page.js
@@ -0,0 +1,72 @@
+/* global document, grist, window */
+
+// Ready message can be configured from url
+const urlParams = new URLSearchParams(window.location.search);
+const ready = urlParams.get('ready') ? JSON.parse(urlParams.get('ready')) : undefined;
+
+if (ready && ready.onEditOptions) {
+ ready.onEditOptions = () => {
+ document.getElementById('configure').innerHTML = 'called';
+ };
+}
+
+grist.ready(ready);
+
+grist.onOptions(data => {
+ document.getElementById('onOptions').innerHTML = JSON.stringify(data);
+});
+
+grist.onRecord((data, mappings) => {
+ document.getElementById('onRecord').innerHTML = JSON.stringify(data);
+ document.getElementById('onRecordMappings').innerHTML = JSON.stringify(mappings);
+});
+
+grist.onRecords((data, mappings) => {
+ document.getElementById('onRecords').innerHTML = JSON.stringify(data);
+ document.getElementById('onRecordsMappings').innerHTML = JSON.stringify(mappings);
+});
+
+async function run(handler) {
+ try {
+ document.getElementById('output').innerText = 'waiting...';
+ const result = await handler(JSON.parse(document.getElementById('input').value || '[]'));
+ document.getElementById('output').innerText = result === undefined ? 'undefined' : JSON.stringify(result);
+ } catch (err) {
+ document.getElementById('output').innerText = JSON.stringify({error: err.message || String(err)});
+ }
+}
+
+// eslint-disable-next-line no-unused-vars
+async function getOptions() {
+ return run(() => grist.widgetApi.getOptions());
+}
+// eslint-disable-next-line no-unused-vars
+async function setOptions() {
+ return run(options => grist.widgetApi.setOptions(...options));
+}
+// eslint-disable-next-line no-unused-vars
+async function setOption() {
+ return run(options => grist.widgetApi.setOption(...options));
+}
+// eslint-disable-next-line no-unused-vars
+async function getOption() {
+ return run(options => grist.widgetApi.getOption(...options));
+}
+// eslint-disable-next-line no-unused-vars
+async function clearOptions() {
+ return run(() => grist.widgetApi.clearOptions());
+}
+// eslint-disable-next-line no-unused-vars
+async function mappings() {
+ return run(() => grist.sectionApi.mappings());
+}
+// eslint-disable-next-line no-unused-vars
+async function configure() {
+ return run((options) => grist.sectionApi.configure(...options));
+}
+
+window.onload = () => {
+ document.getElementById('ready').innerText = 'ready';
+ document.getElementById('access').innerHTML = urlParams.get('access');
+ document.getElementById('readonly').innerHTML = urlParams.get('readonly');
+};
diff --git a/test/fixtures/sites/embed/embed.html b/test/fixtures/sites/embed/embed.html
new file mode 100644
index 00000000..6cc907ab
--- /dev/null
+++ b/test/fixtures/sites/embed/embed.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+ Embed Grist
+
+
+
+
diff --git a/test/fixtures/sites/filter/index.html b/test/fixtures/sites/filter/index.html
new file mode 100644
index 00000000..b16996e0
--- /dev/null
+++ b/test/fixtures/sites/filter/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+ Filter
+ Enter row ids (ie: "1" or "1, 3, 4"):
+
+
+
diff --git a/test/fixtures/sites/filter/page.js b/test/fixtures/sites/filter/page.js
new file mode 100644
index 00000000..b5178fb5
--- /dev/null
+++ b/test/fixtures/sites/filter/page.js
@@ -0,0 +1,13 @@
+
+/* global document, grist, window */
+
+function setup() {
+ grist.ready();
+ grist.allowSelectBy();
+ document.querySelector('#rowIds').addEventListener('change', (ev) => {
+ const rowIds = ev.target.value.split(',').map(Number);
+ grist.setSelectedRows(rowIds);
+ });
+}
+
+window.onload = setup;
diff --git a/test/fixtures/sites/hello/index.html b/test/fixtures/sites/hello/index.html
new file mode 100644
index 00000000..21435435
--- /dev/null
+++ b/test/fixtures/sites/hello/index.html
@@ -0,0 +1,5 @@
+
+
+ Hello World
+
+
diff --git a/test/fixtures/sites/paste/paste.html b/test/fixtures/sites/paste/paste.html
new file mode 100644
index 00000000..a17e572e
--- /dev/null
+++ b/test/fixtures/sites/paste/paste.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+ a |
+ b |
+
+
+ c |
+
+
+ d |
+ e |
+
+
+ f |
+
+
+
+
+
diff --git a/test/fixtures/sites/probe/index.html b/test/fixtures/sites/probe/index.html
new file mode 100644
index 00000000..2d9165fd
--- /dev/null
+++ b/test/fixtures/sites/probe/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+ Probe
+
+
+
diff --git a/test/fixtures/sites/probe/page.js b/test/fixtures/sites/probe/page.js
new file mode 100644
index 00000000..24e57135
--- /dev/null
+++ b/test/fixtures/sites/probe/page.js
@@ -0,0 +1,20 @@
+
+
+/* global document, grist, window */
+
+grist.ready();
+
+function readDoc() {
+ const api = grist.rpc.getStub("GristDocAPI@grist", grist.checkers.GristDocAPI);
+ const placeholder = document.getElementById('placeholder');
+ const fallback = setTimeout(() => {
+ placeholder.innerHTML = 'no joy
';
+ }, 1000);
+ api.listTables()
+ .then(tables => {
+ clearTimeout(fallback);
+ placeholder.innerHTML = `${JSON.stringify(tables)}
`;
+ });
+}
+
+window.onload = readDoc;
diff --git a/test/fixtures/sites/readout/index.html b/test/fixtures/sites/readout/index.html
new file mode 100644
index 00000000..c30f639d
--- /dev/null
+++ b/test/fixtures/sites/readout/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+ Readout
+ placeholder
+
+ rowId
+
+ tableId
+
+
+ record
+
+ records
+
+
+
diff --git a/test/fixtures/sites/readout/page.js b/test/fixtures/sites/readout/page.js
new file mode 100644
index 00000000..dd466612
--- /dev/null
+++ b/test/fixtures/sites/readout/page.js
@@ -0,0 +1,37 @@
+
+
+/* global document, grist, window */
+
+function readDoc() {
+ const fetchTable = grist.docApi.fetchSelectedTable();
+ const placeholder = document.getElementById('placeholder');
+ const fallback = setTimeout(() => {
+ placeholder.innerHTML = 'no joy
';
+ }, 1000);
+ fetchTable
+ .then(table => {
+ clearTimeout(fallback);
+ placeholder.innerHTML = `${JSON.stringify(table)}
`;
+ });
+}
+
+function setup() {
+ grist.ready();
+ grist.on('message', function(e) {
+ if ('options' in e) return;
+ document.getElementById('rowId').innerHTML = e.rowId || '';
+ document.getElementById('tableId').innerHTML = e.tableId || '';
+ readDoc();
+ });
+ grist.onRecord(function(rec) {
+ document.getElementById('record').innerHTML = JSON.stringify(rec);
+ });
+ grist.onRecords(function(recs) {
+ document.getElementById('records').innerHTML = JSON.stringify(recs);
+ });
+ grist.onNewRecord(function(rec) {
+ document.getElementById('record').innerHTML = 'new';
+ });
+}
+
+window.onload = setup;
diff --git a/test/fixtures/sites/types/index.html b/test/fixtures/sites/types/index.html
new file mode 100644
index 00000000..fbc0e749
--- /dev/null
+++ b/test/fixtures/sites/types/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+ Types
+
+ onRecord() matches a record in table?
+
+
+ record
+
+
+
diff --git a/test/fixtures/sites/types/page.js b/test/fixtures/sites/types/page.js
new file mode 100644
index 00000000..dcc2f0cc
--- /dev/null
+++ b/test/fixtures/sites/types/page.js
@@ -0,0 +1,42 @@
+/* global document, grist, window */
+
+function formatValue(value, indent='') {
+ let basic = `${value} [typeof=${typeof value}]`;
+ if (value && typeof value === 'object') {
+ basic += ` [name=${value.constructor.name}]`;
+ }
+ if (value instanceof Date) {
+ // For moment, use moment(value) or moment(value).tz(value.timezone), it's just hard to
+ // include moment into this test fixture.
+ basic += ` [date=${value.toISOString()}]`;
+ }
+ if (value && typeof value === 'object' && value.constructor.name === 'Object') {
+ basic += "\n" + formatObject(value);
+ }
+ return basic;
+}
+
+function formatObject(obj) {
+ const keys = Object.keys(obj).sort();
+ const rows = keys.map(k => `${k}: ${formatValue(obj[k])}`.replace(/\n/g, '\n '));
+ return rows.join("\n");
+}
+
+function setup() {
+ let lastRecords = [];
+ grist.ready();
+ grist.onRecords(function(records) { lastRecords = records; });
+ grist.onRecord(function(rec) {
+ const formatted = formatObject(rec);
+ document.getElementById('record').innerHTML = formatted;
+
+ // Check that there is an identical object in lastRecords, to ensure that onRecords() returns
+ // the same kind of representation.
+ const rowInRecords = lastRecords.find(r => (r.id === rec.id));
+ const match = (formatObject(rowInRecords) === formatted);
+ document.getElementById('match').textContent = String(match);
+
+ });
+}
+
+window.onload = setup;
diff --git a/test/fixtures/sites/zap/index.html b/test/fixtures/sites/zap/index.html
new file mode 100644
index 00000000..f8986e5a
--- /dev/null
+++ b/test/fixtures/sites/zap/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+ Zap
+
+
+
diff --git a/test/fixtures/sites/zap/page.js b/test/fixtures/sites/zap/page.js
new file mode 100644
index 00000000..91671667
--- /dev/null
+++ b/test/fixtures/sites/zap/page.js
@@ -0,0 +1,56 @@
+/* global document, grist, window */
+
+/**
+ * This widget connects to the document, gets a list of all user tables in it,
+ * and then tries to replace all cells with the text 'zap'. It requires full
+ * access to do this.
+ */
+
+let failures = 0;
+function problem(err) {
+ // Trying to zap formula columns will fail, but that's ok.
+ if (String(err).includes("formula column")) { return; }
+ console.error(err);
+ document.getElementById('placeholder').innerHTML = 'zap failed';
+ failures++;
+}
+
+async function zap() {
+ grist.ready();
+ try {
+ // If no access is granted, listTables will hang. Detect this condition with
+ // a timeout.
+ const timeout = setTimeout(() => problem(new Error('cannot connect')), 1000);
+ const tables = await grist.docApi.listTables();
+ clearTimeout(timeout);
+ // Iterate through user tables.
+ for (const tableId of tables) {
+ // Read table content.
+ const data = await grist.docApi.fetchTable(tableId);
+ const ids = data.id;
+ // Prepare to zap all columns except id and manualSort.
+ delete data.id;
+ delete data.manualSort;
+ for (const key of Object.keys(data)) {
+ const column = data[key];
+ for (let i = 0; i < ids.length; i++) {
+ column[i] = 'zap';
+ }
+ // Zap columns one by one since if they are a formula column they will fail.
+ await grist.docApi.applyUserActions([[
+ 'BulkUpdateRecord',
+ tableId,
+ ids,
+ {[key]: column},
+ ]]).catch(problem);
+ }
+ }
+ } catch(err) {
+ problem(err);
+ }
+ if (failures === 0) {
+ document.getElementById('placeholder').innerHTML = 'zap succeeded';
+ }
+}
+
+window.onload = zap;
diff --git a/test/nbrowser/CustomView.ts b/test/nbrowser/CustomView.ts
new file mode 100644
index 00000000..0c8dfe9e
--- /dev/null
+++ b/test/nbrowser/CustomView.ts
@@ -0,0 +1,478 @@
+import {safeJsonParse} from 'app/common/gutil';
+import {assert, driver, Key} from 'mocha-webdriver';
+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);
+ const cleanup = setupTestSuite();
+
+ let serving: Serving;
+
+ before(async function() {
+ if (server.isExternalServer()) {
+ this.skip();
+ }
+ serving = await serveCustomViews();
+ });
+
+ after(async function() {
+ if (serving) {
+ await serving.shutdown();
+ }
+ });
+
+ for (const access of ['none', 'read table', 'full'] as const) {
+
+ function withAccess(obj: any, fallback: any) {
+ return ((access !== 'none') && obj) || fallback;
+ }
+
+ function readJson(txt: string) {
+ return safeJsonParse(txt, null);
+ }
+
+ describe(`with access level ${access}`, function() {
+
+ before(async function() {
+ if (server.isExternalServer()) {
+ this.skip();
+ }
+ const mainSession = await gu.session().teamSite.login();
+ await mainSession.tempDoc(cleanup, 'Favorite_Films.grist');
+ if (!await gu.isSidePanelOpen('right')) {
+ await gu.toggleSidePanel('right');
+ }
+ await driver.find('.test-config-data').click();
+ });
+
+ it('gets appropriate notification of row set changes', async function() {
+ // Link a section on the "All" page of Favorite Films demo
+ await driver.findContent('.test-treeview-itemHeader', /All/).click();
+ await gu.getSection('Friends record').click();
+ await driver.find('.test-pwc-editDataSelection').click();
+ await driver.find('.test-wselect-addBtn').click();
+ await gu.waitForServer();
+ await driver.find('.test-right-select-by').click();
+ await driver.findContent('.test-select-menu li', /Performances record • Film/).click();
+ await driver.find('.test-pwc-editDataSelection').click();
+ await driver.findContent('.test-wselect-type', /Custom/).click();
+ await driver.find('.test-wselect-addBtn').click();
+ await gu.waitForServer();
+
+ // 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 setAccess(access);
+ await gu.waitForServer();
+
+ // Check that the data looks right.
+ const iframe = gu.getSection('Friends record').find('iframe');
+ await driver.switchTo().frame(iframe);
+ assert.deepEqual(readJson(await driver.find('#placeholder').getText()),
+ withAccess({ Name: ["Tom"],
+ Favorite_Film: ["Toy Story"],
+ Age: ["25"],
+ id: [2] }, null));
+ assert.equal(await driver.find('#rowId').getText(), withAccess('2', ''));
+ assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', ''));
+ assert.deepEqual(readJson(await driver.find('#records').getText()),
+ withAccess([{ Name: "Tom", // not a list!
+ Favorite_Film: "Toy Story",
+ Age: "25",
+ id: 2 }], null));
+ await driver.switchTo().defaultContent();
+
+ // Switch row in source section, and see if data updates correctly.
+ await gu.getCell({section: 'Performances record', col: 0, rowNum: 5}).click();
+ await driver.switchTo().frame(iframe);
+ assert.deepEqual(readJson(await driver.find('#placeholder').getText()),
+ withAccess({ Name: ["Roger", "Evan"],
+ Favorite_Film: ["Forrest Gump", "Forrest Gump"],
+ Age: ["22", "35"],
+ id: [1, 5] }, null));
+ assert.equal(await driver.find('#rowId').getText(), withAccess('1', ''));
+ assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', ''));
+ assert.deepEqual(readJson(await driver.find('#records').getText()),
+ withAccess([{ Name: "Roger",
+ Favorite_Film: "Forrest Gump",
+ Age: "22",
+ id: 1 },
+ { Name: "Evan",
+ Favorite_Film: "Forrest Gump",
+ Age: "35",
+ id: 5 }], null));
+ await driver.switchTo().defaultContent();
+ });
+
+ it('gets notification of row changes and content changes', async function() {
+ // Add a custom view linked to Friends
+ await driver.findContent('.test-treeview-itemHeader', /Friends/).click();
+ await driver.findWait('.test-dp-add-new', 1000).doClick();
+ await driver.find('.test-dp-add-widget-to-page').doClick();
+ await driver.findContent('.test-wselect-type', /Custom/).click();
+ await driver.findContent('.test-wselect-table', /Friends/).doClick();
+ await driver.find('.test-wselect-selectby').doClick();
+ await driver.findContent('.test-wselect-selectby option', /FRIENDS/).doClick();
+ await driver.find('.test-wselect-addBtn').click();
+ 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 setAccess(access);
+ await gu.waitForServer();
+
+ // Check that data and cursor looks right
+ const iframe = gu.getSection('Friends custom').find('iframe');
+ await driver.switchTo().frame(iframe);
+ assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name,
+ withAccess(['Roger', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined));
+ assert.equal(await driver.find('#rowId').getText(), withAccess('1', ''));
+ assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', ''));
+ assert.equal(readJson(await driver.find('#record').getText())?.Name,
+ withAccess('Roger', undefined));
+ assert.deepEqual(readJson(await driver.find('#records').getText())?.[0]?.Name,
+ withAccess('Roger', undefined));
+
+ // Change row in Friends
+ await driver.switchTo().defaultContent();
+ await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 2}).click();
+
+ // Check that rowId is updated
+ await driver.switchTo().frame(iframe);
+ assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name,
+ withAccess(['Roger', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined));
+ assert.equal(await driver.find('#rowId').getText(), withAccess('2', ''));
+ assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', ''));
+ assert.equal(readJson(await driver.find('#record').getText())?.Name,
+ withAccess('Tom', undefined));
+ assert.deepEqual(readJson(await driver.find('#records').getText())?.[0]?.Name,
+ withAccess('Roger', undefined));
+ await driver.switchTo().defaultContent();
+
+ // Change a cell in Friends
+ await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click();
+ await gu.enterCell('Rabbit');
+ await gu.waitForServer();
+ // Return to the cell after automatically going to next row.
+ await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click();
+
+ // Check the data in view updates
+ await driver.switchTo().frame(iframe);
+ assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name,
+ withAccess(['Rabbit', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined));
+ assert.equal(await driver.find('#rowId').getText(), withAccess('1', ''));
+ assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', ''));
+ assert.equal(readJson(await driver.find('#record').getText())?.Name,
+ withAccess('Rabbit', undefined));
+ assert.deepEqual(readJson(await driver.find('#records').getText())?.[0]?.Name,
+ withAccess('Rabbit', undefined));
+ await driver.switchTo().defaultContent();
+
+ // Select new row and test if custom view has noticed it.
+ await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 7}).click();
+ await driver.switchTo().frame(iframe);
+ assert.equal(await driver.find('#rowId').getText(), withAccess('new', ''));
+ assert.equal(await driver.find('#record').getText(), withAccess('new', ''));
+ await driver.switchTo().defaultContent();
+ await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click();
+ await driver.switchTo().frame(iframe);
+ assert.equal(await driver.find('#rowId').getText(), withAccess('1', ''));
+ assert.equal(readJson(await driver.find('#record').getText())?.Name, withAccess('Rabbit', undefined));
+ await driver.switchTo().defaultContent();
+
+ // Revert the cell change
+ await gu.undo();
+ });
+
+ 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);
+
+ const iframe = gu.getSection('Friends custom').find('iframe');
+ await driver.switchTo().frame(iframe);
+ await driver.find('body').click();
+
+ // Check that the right secton 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);
+
+ // 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);
+ });
+
+ 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 setAccess(access);
+ await gu.waitForServer();
+
+ // Wait for widget to finish its work.
+ const iframe = gu.getSection('Friends custom').find('iframe');
+ await driver.switchTo().frame(iframe);
+ await gu.waitToPass(async () => {
+ assert.match(await driver.find('#placeholder').getText(), /zap/);
+ }, 10000);
+ const outcome = await driver.find('#placeholder').getText();
+ await driver.switchTo().defaultContent();
+
+ const cell = await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).getText();
+ if (access === 'full') {
+ assert.equal(cell, 'zap');
+ assert.match(outcome, /zap succeeded/);
+ } else {
+ assert.notEqual(cell, 'zap');
+ assert.match(outcome, /zap failed/);
+ }
+ });
+ });
+ }
+
+ it('should receive friendly types when reading data from Grist', async function() {
+ // TODO The same decoding should probably apply to calls like fetchTable() which are satisfied
+ // by the server.
+ const mainSession = await gu.session().teamSite.login();
+ await mainSession.tempDoc(cleanup, 'TypeEncoding.grist');
+ await gu.toggleSidePanel('right', 'open');
+ await driver.find('.test-config-data').click();
+
+ // 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();
+ // 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);
+
+ const iframe = gu.getSection('TYPES custom').find('iframe');
+ await driver.switchTo().frame(iframe);
+ await driver.findContentWait('#record', /AnyDate/, 1000000);
+ let lines = (await driver.find('#record').getText()).split('\n');
+
+ // The first line has regular old values.
+ assert.deepEqual(lines, [
+ "AnyDate: 2020-07-02 [typeof=object] [name=GristDate] [date=2020-07-02T00:00:00.000Z]",
+ "AnyDateTime: 1990-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=1990-08-21T17:19:40.705Z]",
+ "AnyRef: Types[2] [typeof=object] [name=Reference]",
+ "Bool: true [typeof=boolean]",
+ "Date: 2020-07-01 [typeof=object] [name=GristDate] [date=2020-07-01T00:00:00.000Z]",
+ "DateTime: 2020-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=2020-08-21T17:19:40.705Z]",
+ "Numeric: 17.25 [typeof=number]",
+ "RECORD: [object Object] [typeof=object] [name=Object]",
+ " AnyDate: 2020-07-02 [typeof=object] [name=GristDate] [date=2020-07-02T00:00:00.000Z]",
+ " AnyDateTime: 1990-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=1990-08-21T17:19:40.705Z]",
+ " AnyRef: Types[2] [typeof=object] [name=Reference]",
+ " Bool: true [typeof=boolean]",
+ " Date: 2020-07-01 [typeof=object] [name=GristDate] [date=2020-07-01T00:00:00.000Z]",
+ " DateTime: 2020-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=2020-08-21T17:19:40.705Z]",
+ " Numeric: 17.25 [typeof=number]",
+ " Reference: Types[2] [typeof=object] [name=Reference]",
+ " Text: Hello! [typeof=string]",
+ " id: 24 [typeof=number]",
+ "Reference: Types[2] [typeof=object] [name=Reference]",
+ "Text: Hello! [typeof=string]",
+ "id: 24 [typeof=number]",
+ ]);
+
+ // #match tells us if onRecords() returned the same representation for this record.
+ assert.equal(await driver.find('#match').getText(), 'true');
+
+ // Switch to the next row, which has blank values.
+ await driver.switchTo().defaultContent();
+ await gu.getCell({section: 'TYPES', col: 0, rowNum: 2}).click();
+ await driver.switchTo().frame(iframe);
+ await driver.findContentWait('#record', /AnyDate: null/, 1000);
+ lines = (await driver.find('#record').getText()).split('\n');
+ assert.deepEqual(lines, [
+ "AnyDate: null [typeof=object]",
+ "AnyDateTime: null [typeof=object]",
+ "AnyRef: Types[0] [typeof=object] [name=Reference]",
+ "Bool: false [typeof=boolean]",
+ "Date: null [typeof=object]",
+ "DateTime: null [typeof=object]",
+ "Numeric: 0 [typeof=number]",
+ "RECORD: [object Object] [typeof=object] [name=Object]",
+ " AnyDate: null [typeof=object]",
+ " AnyDateTime: null [typeof=object]",
+ " AnyRef: Types[0] [typeof=object] [name=Reference]",
+ " Bool: false [typeof=boolean]",
+ " Date: null [typeof=object]",
+ " DateTime: null [typeof=object]",
+ " Numeric: 0 [typeof=number]",
+ " Reference: Types[0] [typeof=object] [name=Reference]",
+ " Text: [typeof=string]",
+ " id: 1 [typeof=number]",
+ "Reference: Types[0] [typeof=object] [name=Reference]",
+ "Text: [typeof=string]",
+ "id: 1 [typeof=number]",
+ ]);
+
+ // #match tells us if onRecords() returned the same representation for this record.
+ assert.equal(await driver.find('#match').getText(), 'true');
+
+ // Switch to the next row, which has various error values.
+ await driver.switchTo().defaultContent();
+ await gu.getCell({section: 'TYPES', col: 0, rowNum: 3}).click();
+ await driver.switchTo().frame(iframe);
+ await driver.findContentWait('#record', /AnyDate: null/, 1000);
+ lines = (await driver.find('#record').getText()).split('\n');
+
+ assert.deepEqual(lines, [
+ "AnyDate: #Invalid Date: Not-a-Date [typeof=object] [name=RaisedException]",
+ "AnyDateTime: #Invalid DateTime: Not-a-DateTime [typeof=object] [name=RaisedException]",
+ "AnyRef: #AssertionError [typeof=object] [name=RaisedException]",
+ "Bool: true [typeof=boolean]",
+ "Date: Not-a-Date [typeof=string]",
+ "DateTime: Not-a-DateTime [typeof=string]",
+ "Numeric: Not-a-Number [typeof=string]",
+ "RECORD: [object Object] [typeof=object] [name=Object]",
+ " AnyDate: null [typeof=object]",
+ " AnyDateTime: null [typeof=object]",
+ " AnyRef: null [typeof=object]",
+ " Bool: true [typeof=boolean]",
+ " Date: Not-a-Date [typeof=string]",
+ " DateTime: Not-a-DateTime [typeof=string]",
+ " Numeric: Not-a-Number [typeof=string]",
+ " Reference: No-Ref [typeof=string]",
+ " Text: Errors [typeof=string]",
+ " _error_: [object Object] [typeof=object] [name=Object]",
+ " AnyDate: InvalidTypedValue: Invalid Date: Not-a-Date [typeof=string]",
+ " AnyDateTime: InvalidTypedValue: Invalid DateTime: Not-a-DateTime [typeof=string]",
+ " AnyRef: AssertionError: [typeof=string]",
+ " id: 2 [typeof=number]",
+ "Reference: No-Ref [typeof=string]",
+ "Text: Errors [typeof=string]",
+ "id: 2 [typeof=number]",
+ ]);
+
+ // #match tells us if onRecords() returned the same representation for this record.
+ assert.equal(await driver.find('#match').getText(), 'true');
+ });
+
+ it('respect access rules', async function() {
+ // Create a Favorite Films copy, with access rules on columns, rows, and tables.
+ const mainSession = await gu.session().teamSite.login();
+ const api = mainSession.createHomeApi();
+ const doc = await mainSession.tempDoc(cleanup, 'Favorite_Films.grist', {load: false});
+ await api.applyUserActions(doc.id, [
+ ['AddTable', 'Opinions', [{id: 'A'}]],
+ ['AddRecord', 'Opinions', null, {A: 'do not zap plz'}],
+ ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Performances', colIds: 'Actor'}],
+ ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Films', colIds: '*'}],
+ ['AddRecord', '_grist_ACLResources', -3, {tableId: 'Opinions', colIds: '*'}],
+ ['AddRecord', '_grist_ACLRules', null, {
+ resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'none',
+ }],
+ ['AddRecord', '_grist_ACLRules', null, {
+ resource: -2, aclFormula: 'rec.id % 2 == 0', permissionsText: 'none',
+ }],
+ ['AddRecord', '_grist_ACLRules', null, {
+ resource: -3, aclFormula: '', permissionsText: 'none',
+ }],
+ ]);
+
+ // Open it up and add a new linked section.
+ await mainSession.loadDoc(`/doc/${doc.id}`);
+ await gu.toggleSidePanel('right', 'open');
+ await driver.find('.test-config-data').click();
+ await driver.findContent('.test-treeview-itemHeader', /All/).click();
+ await gu.getSection('Friends record').click();
+ await driver.find('.test-pwc-editDataSelection').click();
+ await driver.find('.test-wselect-addBtn').click();
+ await gu.waitForServer();
+ await driver.find('.test-right-select-by').click();
+ await driver.findContent('.test-select-menu li', /Performances record • Film/).click();
+ await driver.find('.test-pwc-editDataSelection').click();
+ await driver.findContent('.test-wselect-type', /Custom/).click();
+ await driver.find('.test-wselect-addBtn').click();
+ 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 setAccess("full");
+ await gu.waitForServer();
+
+ // Wait for widget to finish its work.
+ const iframe = gu.getSection('Friends record').find('iframe');
+ await driver.switchTo().frame(iframe);
+ await gu.waitToPass(async () => {
+ assert.match(await driver.find('#placeholder').getText(), /zap/);
+ }, 10000);
+ await driver.switchTo().defaultContent();
+
+ // Now leave the page and remove all access rules.
+ await mainSession.loadDocMenu('/');
+ await api.applyUserActions(doc.id, [
+ ['BulkRemoveRecord', '_grist_ACLRules', [2, 3, 4]]
+ ]);
+
+ // Check that the expected cells got zapped.
+
+ // In performances table, all but Actor column should have been zapped.
+ const performances = await api.getDocAPI(doc.id).getRows('Performances');
+ let keys = Object.keys(performances);
+ for (let i = 0; i < performances.id.length; i++) {
+ for (const key of keys) {
+ if (key !== 'Actor' && key !== 'id' && key !== 'manualSort') {
+ // use match since zap may be embedded in an error, e.g. if inserted in ref column.
+ assert.match(String(performances[key][i]), /zap/);
+ }
+ assert.notMatch(String(performances['Actor'][i]), /zap/);
+ }
+ }
+
+ // In films table, every second row should have been zapped.
+ const films = await api.getDocAPI(doc.id).getRows('Films');
+ keys = Object.keys(films);
+ for (let i = 0; i < films.id.length; i++) {
+ for (const key of keys) {
+ if (key !== 'id' && key !== 'manualSort') {
+ assert.equal(films[key][i] === 'zap', films.id[i] % 2 === 1);
+ }
+ }
+ }
+
+ // Opinions table should be untouched.
+ const opinions = await api.getDocAPI(doc.id).getRows('Opinions');
+ assert.equal(opinions['A'][0], 'do not zap plz');
+ });
+});
diff --git a/test/nbrowser/CustomWidgets.ts b/test/nbrowser/CustomWidgets.ts
new file mode 100644
index 00000000..23bfd084
--- /dev/null
+++ b/test/nbrowser/CustomWidgets.ts
@@ -0,0 +1,583 @@
+import {assert, driver, Key} from 'mocha-webdriver';
+import * as gu from 'test/nbrowser/gristUtils';
+import {server, setupTestSuite} from 'test/nbrowser/testUtils';
+import {serveSomething} from 'test/server/customUtil';
+import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget';
+import {AccessTokenResult} from 'app/plugin/GristAPI';
+import {TableOperations} from 'app/plugin/TableOperations';
+import {getAppRoot} from 'app/server/lib/places';
+import fetch from 'node-fetch';
+import * as path from 'path';
+
+// Valid manifest url.
+const manifestEndpoint = '/manifest.json';
+// Valid widget url.
+const widgetEndpoint = '/widget';
+// Custom URL label in selectbox.
+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 fromAccess = (level: AccessLevel) =>
+ ({widgetId: level, name: level, url: widgetEndpoint, accessLevel: level}) as ICustomWidget;
+const widgetNone = fromAccess(AccessLevel.none);
+const widgetRead = fromAccess(AccessLevel.read_table);
+const widgetFull = fromAccess(AccessLevel.full);
+
+// Holds widgets manifest content.
+let widgets: ICustomWidget[] = [];
+
+describe('CustomWidgets', function () {
+ this.timeout(20000);
+ const cleanup = setupTestSuite();
+
+ // Holds url for sample widget server.
+ let widgetServerUrl = '';
+
+ // Switches widget manifest url
+ function useManifest(url: string) {
+ return server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : '');
+ }
+
+ 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
+ app.get('/500', (_, res) => res.sendStatus(500).end()); // internal error
+ app.get('/200', (_, res) => res.sendStatus(200).end()); // valid response with OK
+ app.get('/401', (_, res) => res.sendStatus(401).end()); // unauthorized
+ app.get('/403', (_, res) => res.sendStatus(403).end()); // forbidden
+ app.get(widgetEndpoint, (req, res) =>
+ res
+ .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()
+ );
+ app.get(manifestEndpoint, (_, res) =>
+ res
+ .header('Content-Type', 'application/json')
+ // prefix widget endpoint with server address
+ .json(widgets.map(widget => ({...widget, url: `${widgetServerUrl}${widget.url}`})))
+ .end()
+ );
+ app.get('/grist-plugin-api.js', (_, res) =>
+ res.sendFile(
+ 'grist-plugin-api.js', {
+ root: path.resolve(getAppRoot(), "static")
+ }));
+ });
+
+ cleanup.addAfterAll(widgetServer.shutdown);
+ widgetServerUrl = widgetServer.url;
+
+ // Start with valid endpoint and 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/});
+
+ // Override gristConfig to enable widget list.
+ await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
+ });
+
+ // Open or close widget menu.
+ const toggle = () => driver.find('.test-config-widget-select .test-select-open').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();
+ };
+ // Get rendered content from custom section.
+ const content = async () => {
+ const iframe = driver.find('iframe');
+ await driver.switchTo().frame(iframe);
+ const text = await driver.find('body').getText();
+ await driver.switchTo().defaultContent();
+ return text;
+ };
+ async function execute(
+ op: (table: TableOperations) => Promise,
+ tableSelector: (grist: any) => TableOperations = (grist) => grist.selectedTable
+ ) {
+ const iframe = await driver.find('iframe');
+ await driver.switchTo().frame(iframe);
+ try {
+ const harness = async (done: any) => {
+ const grist = (window as any).grist;
+ grist.ready();
+ const table = tableSelector(grist);
+ try {
+ let result = await op(table);
+ if (result === undefined) {
+ result = "__undefined__";
+ }
+ done(result);
+ } catch (e) {
+ done(String(e.message || e));
+ }
+ };
+ const cmd =
+ 'const done = arguments[arguments.length - 1];\n' +
+ 'const op = ' + op.toString() + ';\n' +
+ 'const tableSelector = ' + tableSelector.toString() + ';\n' +
+ 'const harness = ' + harness.toString() + ';\n' +
+ 'harness(done);\n';
+ const result = await driver.executeAsyncScript(cmd);
+ // done callback will return null instead of undefined
+ return result === "__undefined__" ? undefined : result;
+ } finally {
+ await driver.switchTo().defaultContent();
+ }
+ }
+ // 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.clearInput();
+ 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.
+ async function recreatePanel() {
+ await gu.getSection('TABLE1').click();
+ await gu.getSection('TABLE1 Custom').click();
+ await gu.waitForServer();
+ }
+ // Gets or sets access level
+ async function access(level?: AccessLevel) {
+ const text = {
+ [AccessLevel.none] : "No document access",
+ [AccessLevel.read_table]: "Read selected table",
+ [AccessLevel.full]: "Full document access"
+ };
+ if (!level) {
+ const currentAccess = await driver.find('.test-config-widget-access .test-select-open').getText();
+ return Object.entries(text).find(e => e[1] === currentAccess)![0];
+ } else {
+ await driver.find('.test-config-widget-access .test-select-open').click();
+ await driver.findContent('.test-select-menu li', text[level]).click();
+ await gu.waitForServer();
+ }
+ }
+
+ // Checks if access prompt is visible.
+ const hasPrompt = () => driver.find(".test-config-widget-access-accept").isPresent();
+ // Accepts new access level.
+ const accept = () => driver.find(".test-config-widget-access-accept").click();
+ // Rejects new access level.
+ const reject = () => driver.find(".test-config-widget-access-reject").click();
+
+ it('should show widgets in dropdown', async () => {
+ await gu.toggleSidePanel('right');
+ await driver.find('.test-config-widget').click();
+ await gu.waitForServer(); // Wait for widgets to load.
+
+ // Selectbox should have select label.
+ assert.equal(await current(), CUSTOM_URL);
+
+ // 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 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');
+ assert.equal(await content(), 'OK');
+
+ // Test first widget.
+ await toggle();
+ await select(widget1.name);
+ assert.equal(await current(), widget1.name);
+ assert.equal(await content(), widget1.name);
+
+ // Test second widget.
+ await toggle();
+ await select(widget2.name);
+ assert.equal(await current(), widget2.name);
+ 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');
+ 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 recreatePanel();
+ assert.equal(await current(), CUSTOM_URL);
+ await gu.undo(7);
+ });
+
+ it('should show error message for invalid widget url list', async () => {
+ const testError = async (url: string, error: string) => {
+ // Switch section to rebuild the creator panel.
+ await useManifest(url);
+ 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();
+ };
+
+ await testError('/404', "Remote widget list not found");
+ await testError('/500', "Remote server returned an error");
+ await testError('/401', "Remote server returned an error");
+ await testError('/403', "Remote server returned an error");
+ // Invalid content in a response.
+ await testError('/200', "Error reading widget list");
+
+ // Reset to valid manifest.
+ await useManifest(manifestEndpoint);
+ await recreatePanel();
+ });
+
+ it('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);
+ widgets = [widget2];
+ // Invalidate cache.
+ await useManifest(manifestEndpoint);
+ // 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 content(), widget1.name);
+ await gu.undo(1);
+ });
+
+ it('should switch access level to none on new widget', async () => {
+ widgets = [widget1, widget2];
+ await useManifest(manifestEndpoint);
+ await recreatePanel();
+
+ await toggle();
+ await select(widget1.name);
+ assert.equal(await access(), AccessLevel.none);
+ await access(AccessLevel.full);
+ assert.equal(await access(), AccessLevel.full);
+
+ await toggle();
+ await select(widget2.name);
+ assert.equal(await access(), AccessLevel.none);
+ await access(AccessLevel.full);
+ assert.equal(await access(), AccessLevel.full);
+
+ await toggle();
+ await select(CUSTOM_URL);
+ assert.equal(await access(), AccessLevel.none);
+ await access(AccessLevel.full);
+ assert.equal(await access(), AccessLevel.full);
+
+ await toggle();
+ await select(widget2.name);
+ assert.equal(await access(), AccessLevel.none);
+ await access(AccessLevel.full);
+ assert.equal(await access(), AccessLevel.full);
+
+ await gu.undo(8);
+ });
+
+ it('should prompt for access change', async () => {
+ widgets = [widget1, widget2, widgetFull, widgetNone, widgetRead];
+ await useManifest(manifestEndpoint);
+ await recreatePanel();
+
+ const test = async (w: ICustomWidget) => {
+ // Select widget without desired access level
+ await toggle();
+ await select(widget1.name);
+ assert.isFalse(await hasPrompt());
+ assert.equal(await access(), AccessLevel.none);
+
+ // Select one with desired access level
+ await toggle();
+ await select(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);
+ assert.isTrue(await hasPrompt());
+
+ // Accept, and test if prompt is hidden, and level stays
+ await accept();
+ assert.isFalse(await hasPrompt());
+ assert.equal(await access(), w.accessLevel);
+
+ // Do the same, but this time reject
+ await toggle();
+ await select(widget1.name);
+ assert.isFalse(await hasPrompt());
+ assert.equal(await access(), AccessLevel.none);
+
+ await toggle();
+ await select(w.name);
+ assert.isTrue(await hasPrompt());
+ assert.equal(await content(), AccessLevel.none);
+
+ await reject();
+ assert.isFalse(await hasPrompt());
+ assert.equal(await access(), AccessLevel.none);
+ assert.equal(await content(), AccessLevel.none);
+ };
+
+ await test(widgetFull);
+ await test(widgetRead);
+ });
+
+ it('should auto accept none access level', async () => {
+ // Select widget without access level
+ await toggle();
+ await select(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);
+ assert.isFalse(await hasPrompt());
+ assert.equal(await access(), AccessLevel.none);
+ assert.equal(await content(), AccessLevel.none);
+ });
+
+ it('should show prompt when user switches sections', async () => {
+ // Select widget without access level
+ await toggle();
+ await select(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);
+ assert.isTrue(await hasPrompt());
+
+ // Switch section, and test if prompt is hidden
+ await recreatePanel();
+ assert.isTrue(await hasPrompt());
+ assert.equal(await access(), AccessLevel.none);
+ assert.equal(await content(), AccessLevel.none);
+ });
+
+ it('should hide prompt when user switches widget', async () => {
+ // Select widget without access level
+ await toggle();
+ await select(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);
+ assert.isTrue(await hasPrompt());
+
+ // Switch to another level.
+ await toggle();
+ await select(widget1.name);
+ assert.isFalse(await hasPrompt());
+ assert.equal(await access(), AccessLevel.none);
+ });
+
+ 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);
+ assert.isFalse(await hasPrompt());
+ assert.equal(await access(), AccessLevel.none);
+ assert.equal(await content(), AccessLevel.none);
+ };
+
+ // Selects widget with full access level
+ const selectFull = async () => {
+ await toggle();
+ await select(widgetFull.name);
+ assert.isTrue(await hasPrompt());
+ assert.equal(await content(), AccessLevel.none);
+ assert.equal(await content(), AccessLevel.none);
+ };
+
+ await selectNone();
+ await selectFull();
+
+ // Select the same level.
+ await access(AccessLevel.full);
+ assert.isFalse(await hasPrompt());
+ assert.equal(await access(), AccessLevel.full);
+ assert.equal(await content(), AccessLevel.full);
+
+ await selectNone();
+ await selectFull();
+
+ // Select the normal level, prompt should be still there, as widget needs a higher permission.
+ await access(AccessLevel.read_table);
+ assert.isTrue(await hasPrompt());
+ assert.equal(await access(), AccessLevel.read_table);
+ assert.equal(await content(), AccessLevel.read_table);
+
+ await selectNone();
+ await selectFull();
+
+ // Select the none level.
+ await access(AccessLevel.none);
+ assert.isTrue(await hasPrompt());
+ assert.equal(await access(), AccessLevel.none);
+ assert.equal(await content(), AccessLevel.none);
+ });
+
+ it("should support grist.selectedTable", async () => {
+ // Open a custom widget with full access.
+ 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.
+ await execute(async (table) => {
+ await table.upsert({
+ require: {A: 'hello'},
+ fields: {A: 'goodbye'}
+ });
+ });
+ await gu.waitToPass(async () => {
+ assert.equal(await gu.getCell({section: 'TABLE1', rowNum: 1, col: 0}).getText(), 'goodbye');
+ });
+
+ // Check an update works.
+ await execute(async table => {
+ return table.update({
+ id: 2,
+ fields: {A: 'farewell'}
+ });
+ });
+ await gu.waitToPass(async () => {
+ assert.equal(await gu.getCell({section: 'TABLE1', rowNum: 2, col: 0}).getText(), 'farewell');
+ });
+
+ // Check options are passed along.
+ await execute(async table => {
+ return table.upsert({
+ require: {},
+ fields: {A: 'goodbyes'}
+ }, {onMany: 'all', allowEmptyRequire: true});
+ });
+ await gu.waitToPass(async () => {
+ assert.equal(await gu.getCell({section: 'TABLE1', rowNum: 1, col: 0}).getText(), 'goodbyes');
+ assert.equal(await gu.getCell({section: 'TABLE1', rowNum: 2, col: 0}).getText(), 'goodbyes');
+ });
+
+ // Check a create works.
+ const {id} = await execute(async table => {
+ return table.create({
+ fields: {A: 'partA', B: 'partB'}
+ });
+ }) as {id: number};
+ assert.equal(id, 5);
+ await gu.waitToPass(async () => {
+ assert.equal(await gu.getCell({section: 'TABLE1', rowNum: id, col: 0}).getText(), 'partA');
+ assert.equal(await gu.getCell({section: 'TABLE1', rowNum: id, col: 1}).getText(), 'partB');
+ });
+
+ // Check a destroy works.
+ let result = await execute(async table => {
+ await table.destroy(1);
+ });
+ assert.isUndefined(result);
+ await gu.waitToPass(async () => {
+ assert.equal(await gu.getCell({section: 'TABLE1', rowNum: id - 1, col: 0}).getText(), 'partA');
+ });
+ result = await execute(async table => {
+ await table.destroy([2]);
+ });
+ assert.isUndefined(result);
+ await gu.waitToPass(async () => {
+ assert.equal(await gu.getCell({section: 'TABLE1', rowNum: id - 2, col: 0}).getText(), 'partA');
+ });
+
+ // Check errors are friendly.
+ const errMessage = await execute(async table => {
+ await table.create({fields: {ziggy: 1}});
+ });
+ assert.equal(errMessage, 'Invalid column "ziggy"');
+ });
+
+ it("should support grist.getTable", async () => {
+ // Check an update on an existing table works.
+ await execute(async table => {
+ return table.update({
+ id: 3,
+ fields: {A: 'back again'}
+ });
+ }, (grist) => grist.getTable('Table1'));
+ await gu.waitToPass(async () => {
+ assert.equal(await gu.getCell({section: 'TABLE1', rowNum: 1, col: 0}).getText(), 'back again');
+ });
+
+ // Check an update on a nonexistent table fails.
+ assert.match(String(await execute(async table => {
+ return table.update({
+ id: 3,
+ fields: {A: 'back again'}
+ });
+ }, (grist) => grist.getTable('Table2'))), /Table not found/);
+ });
+
+ it("should support grist.getAccessTokens", async () => {
+ const iframe = await driver.find('iframe');
+ await driver.switchTo().frame(iframe);
+ try {
+ const tokenResult: AccessTokenResult = await driver.executeAsyncScript(
+ (done: any) => (window as any).grist.getAccessToken().then(done)
+ );
+ assert.sameMembers(Object.keys(tokenResult), ['ttlMsecs', 'token', 'baseUrl']);
+ const result = await fetch(tokenResult.baseUrl + `/tables/Table1/records?auth=${tokenResult.token}`);
+ assert.sameMembers(Object.keys(await result.json()), ['records']);
+ } finally {
+ await driver.switchTo().defaultContent();
+ }
+ });
+
+ it('should offer only custom url when disabled', async () => {
+ await toggle();
+ await select(CUSTOM_URL);
+ 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());
+ });
+});
diff --git a/test/nbrowser/CustomWidgetsConfig.ts b/test/nbrowser/CustomWidgetsConfig.ts
new file mode 100644
index 00000000..965dd303
--- /dev/null
+++ b/test/nbrowser/CustomWidgetsConfig.ts
@@ -0,0 +1,952 @@
+import {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';
+
+// Valid manifest url.
+const manifestEndpoint = '/manifest.json';
+
+let docId = '';
+
+// Tester widget name.
+const TESTER_WIDGET = 'Tester';
+const NORMAL_WIDGET = 'Normal';
+const READ_WIDGET = 'Read';
+const FULL_WIDGET = 'Full';
+const COLUMN_WIDGET = 'COLUMN_WIDGET';
+// Custom URL label in selectbox.
+const CUSTOM_URL = 'Custom URL';
+// Holds url for sample widget server.
+let widgetServerUrl = '';
+
+// Creates url for Config Widget passing ready arguments in URL. This is not builtin method, Config Widget understands
+// this parameter and is using it as an argument for the ready method.
+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();
+};
+// Persists custom options.
+const persistOptions = () => click('.test-section-menu-small-btn-save');
+
+// Helpers to create test ids for column pickers
+const pickerLabel = (name: string) => `.test-config-widget-label-for-${name}`;
+const pickerDrop = (name: string) => `.test-config-widget-mapping-for-${name}`;
+const pickerAdd = (name: string) => `.test-config-widget-add-column-for-${name}`;
+
+// Helpers to work with menus
+async function clickMenuItem(name: string) {
+ await driver.findContent('.grist-floating-menu li', name).click();
+ await gu.waitForServer();
+}
+const getMenuOptions = () => driver.findAll('.grist-floating-menu li', el => el.getText());
+async function getListItems(col: string) {
+ return await driver
+ .findAll(`.test-config-widget-map-list-for-${col} .test-config-widget-ref-select-label`, el => el.getText());
+}
+
+// Gets or sets access level
+async function givenAccess(level?: AccessLevel) {
+ const text = {
+ [AccessLevel.none]: 'No document access',
+ [AccessLevel.read_table]: 'Read selected table',
+ [AccessLevel.full]: 'Full document access',
+ };
+ if (!level) {
+ const currentAccess = await driver.find('.test-config-widget-access .test-select-open').getText();
+ return Object.entries(text).find(e => e[1] === currentAccess)![0];
+ } else {
+ await driver.find('.test-config-widget-access .test-select-open').click();
+ await driver.findContent('.test-select-menu li', text[level]).click();
+ await gu.waitForServer();
+ }
+}
+
+// Checks if access prompt is visible.
+const hasPrompt = () => driver.find('.test-config-widget-access-accept').isPresent();
+// Accepts new access level.
+const accept = () => driver.find('.test-config-widget-access-accept').click();
+// 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');
+}
+
+async function selectAccess(access: string) {
+ // if the current access is ok do nothing
+ if ((await givenAccess()) === access) {
+ // unless we need to confirm it
+ if (await hasPrompt()) {
+ await accept();
+ }
+ } else {
+ // else switch access level
+ await givenAccess(access as AccessLevel);
+ }
+}
+
+// Checks if active section has option in the menu to open configuration
+async function hasSectionOption() {
+ const menu = await gu.openSectionMenu('viewLayout');
+ const has = 1 === (await menu.findAll('.test-section-open-configuration')).length;
+ await driver.sendKeys(Key.ESCAPE);
+ return has;
+}
+
+async function saveMenu() {
+ await driver.findWait('.active_section .test-section-menu-small-btn-save', 100).click();
+ await gu.waitForServer();
+}
+
+async function revertMenu() {
+ await driver.findWait('.active_section .test-section-menu-small-btn-revert', 100).click();
+}
+
+async function clearOptions() {
+ await gu.openSectionMenu('sortAndFilter');
+ await driver.findWait('.test-section-menu-btn-remove-options', 100).click();
+ await driver.sendKeys(Key.ESCAPE);
+}
+
+// Check if the Sort menu is in correct state
+async function checkSortMenu(state: 'empty' | 'modified' | 'customized' | 'emptyNotSaved') {
+ // for modified and emptyNotSaved menu should be greyed and buttons should be hidden
+ if (state === 'modified' || state === 'emptyNotSaved') {
+ assert.isTrue(await driver.find('.active_section .test-section-menu-wrapper').matches('[class*=-unsaved]'));
+ } else {
+ assert.isFalse(await driver.find('.active_section .test-section-menu-wrapper').matches('[class*=-unsaved]'));
+ }
+ // open menu
+ await gu.openSectionMenu('sortAndFilter');
+ // for modified state, there should be buttons save and revert
+ if (state === 'modified' || state === 'emptyNotSaved') {
+ assert.isTrue(await driver.find('.test-section-menu-btn-save').isPresent());
+ } else {
+ assert.isFalse(await driver.find('.test-section-menu-btn-save').isPresent());
+ }
+ const text = await driver.find('.test-section-menu-custom-options').getText();
+ if (state === 'empty' || state === 'emptyNotSaved') {
+ assert.equal(text, '(empty)');
+ } else if (state === 'modified') {
+ assert.equal(text, '(modified)');
+ } else if (state === 'customized') {
+ assert.equal(text, '(customized)');
+ }
+ // there should be option to delete custom options
+ if (state === 'empty' || state === 'emptyNotSaved') {
+ assert.isFalse(await driver.find('.test-section-menu-btn-remove-options').isPresent());
+ } else {
+ assert.isTrue(await driver.find('.test-section-menu-btn-remove-options').isPresent());
+ }
+ await driver.sendKeys(Key.ESCAPE);
+}
+
+describe('CustomWidgetsConfig', function () {
+ this.timeout(30000); // almost 20 second on dev machine.
+ const cleanup = setupTestSuite();
+ let mainSession: gu.Session;
+ gu.bigScreen();
+
+ 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('/manifest.json', (_, res) => {
+ res.json([
+ {
+ // Main Custom Widget with onEditOptions handler.
+ name: TESTER_WIDGET,
+ url: createConfigUrl({onEditOptions: true}),
+ widgetId: 'tester1',
+ },
+ {
+ // Widget without ready options.
+ name: NORMAL_WIDGET,
+ url: createConfigUrl(),
+ widgetId: 'tester2',
+ },
+ {
+ // Widget requesting read access.
+ name: READ_WIDGET,
+ url: createConfigUrl({requiredAccess: AccessLevel.read_table}),
+ widgetId: 'tester3',
+ },
+ {
+ // Widget requesting full access.
+ name: FULL_WIDGET,
+ url: createConfigUrl({requiredAccess: AccessLevel.full}),
+ widgetId: 'tester4',
+ },
+ {
+ // Widget with column mapping
+ name: COLUMN_WIDGET,
+ url: createConfigUrl({requiredAccess: AccessLevel.read_table, columns: ['Column']}),
+ widgetId: 'tester5',
+ },
+ ]);
+ });
+ addStatic(app);
+ });
+ cleanup.addAfterAll(widgetServer.shutdown);
+ widgetServerUrl = widgetServer.url;
+ await server.testingHooks.setWidgetRepositoryUrl(`${widgetServerUrl}${manifestEndpoint}`);
+
+ 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');
+ });
+
+ // Poor man widget rpc. Class that invokes various parts in the tester widget.
+ class Widget {
+ constructor(public frameSelector = 'iframe') {}
+ // Wait for a frame.
+ public async waitForFrame() {
+ await driver.wait(() => driver.find(this.frameSelector).isPresent(), 1000);
+ const iframe = driver.find(this.frameSelector);
+ await driver.switchTo().frame(iframe);
+ await driver.wait(async () => (await driver.find('#ready').getText()) === 'ready', 1000);
+ await driver.switchTo().defaultContent();
+ }
+ 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 onRecordsMappings() {
+ const text = await this._read('#onRecordsMappings');
+ return JSON.parse(text || 'null');
+ }
+ // Wait for frame to close.
+ public async waitForClose() {
+ await driver.wait(async () => !(await driver.find(this.frameSelector).isPresent()), 1000);
+ }
+ // Wait for the onOptions event, and return its value.
+ public async onOptions() {
+ const iframe = driver.find(this.frameSelector);
+ await driver.switchTo().frame(iframe);
+ // 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(), 1000);
+ const text = await driver.find('#onOptions').getText();
+ await driver.switchTo().defaultContent();
+ 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');
+ }
+ // 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(this.frameSelector);
+ 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) {
+ const iframe = driver.find(this.frameSelector);
+ await driver.switchTo().frame(iframe);
+ const text = await driver.find(selector).getText();
+ await driver.switchTo().defaultContent();
+ return text;
+ }
+ }
+ // 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.
+ if ((await currentWidget()) !== CUSTOM_URL) {
+ await toggleWidgetMenu();
+ await clickOption(CUSTOM_URL);
+ }
+ await toggleWidgetMenu();
+ await clickOption(TESTER_WIDGET);
+ });
+
+ 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 widget.waitForFrame();
+ await accept();
+ // Visible columns section should be hidden.
+ assert.isFalse(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
+ // Record event should be fired.
+ assert.deepEqual(await widget.onRecords(), [
+ {id: 1, A: 'A' },
+ {id: 2, A: 'B' },
+ {id: 3, A: 'C' },
+ ]);
+ // Mappings should null at first.
+ assert.isNull(await widget.onRecordsMappings());
+ // We should see a single Column picker.
+ assert.isTrue(await driver.find('.test-config-widget-label-for-Column').isPresent());
+ // With single column to map.
+ await toggleDrop(pickerDrop('Column'));
+ assert.deepEqual(await getOptions(), ['A']);
+ await clickOption('A');
+ await gu.waitForServer();
+ // Widget should receive mappings
+ assert.deepEqual(await widget.onRecordsMappings(), {Column: 'A'});
+ await revert();
+ });
+
+ 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(
+ createConfigUrl({
+ columns: ['M1', {name: 'M2', optional: true}, {name: 'M3', title: 'T3'}, {name: 'M4', type: 'Text'}],
+ requiredAccess: 'read table',
+ })
+ );
+ await accept();
+ const empty = {M1: null, M2: null, M3: null, M4: null};
+ await widget.waitForFrame();
+ assert.isNull(await widget.onRecordsMappings());
+ // We should see 4 pickers
+ assert.isTrue(await driver.find(pickerLabel('M1')).isPresent());
+ assert.isTrue(await driver.find(pickerLabel('M2')).isPresent());
+ assert.isTrue(await driver.find(pickerLabel('M3')).isPresent());
+ assert.isTrue(await driver.find(pickerLabel('M4')).isPresent());
+ assert.equal(await driver.find(pickerLabel('M1')).getText(), 'M1');
+ assert.equal(await driver.find(pickerLabel('M2')).getText(), 'M2 (optional)');
+ // Label for picker M3 should have alternative text;
+ assert.equal(await driver.find(pickerLabel('M3')).getText(), 'T3');
+ assert.equal(await driver.find(pickerLabel('M4')).getText(), 'M4');
+ // All picker should show "Pick a column" except M4, which should say "Pick a text column"
+ assert.equal(await driver.find(pickerDrop('M1')).getText(), 'Pick a column');
+ assert.equal(await driver.find(pickerDrop('M2')).getText(), 'Pick a column');
+ assert.equal(await driver.find(pickerDrop('M3')).getText(), 'Pick a column');
+ assert.equal(await driver.find(pickerDrop('M4')).getText(), 'Pick a text column');
+ // Mappings should be empty
+ assert.isNull(await widget.onRecordsMappings());
+ // Should be able to select column A for all options
+ await toggleDrop(pickerDrop('M1'));
+ await clickOption('A');
+ await gu.waitForServer();
+ assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A'});
+ await toggleDrop(pickerDrop('M2'));
+ await clickOption('A');
+ await gu.waitForServer();
+ assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A', M2: 'A'});
+ await toggleDrop(pickerDrop('M3'));
+ await clickOption('A');
+ await gu.waitForServer();
+ assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A', M2: 'A', M3: 'A'});
+ await toggleDrop(pickerDrop('M4'));
+ await clickOption('A');
+ await gu.waitForServer();
+ 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);
+ assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A'});
+ // Add another columns, numeric B and any C.
+ await gu.selectSectionByTitle('Table');
+ await gu.addColumn('B');
+ await gu.getCell('B', 1).click();
+ await gu.enterCell('99');
+ await gu.addColumn('C');
+ await gu.selectSectionByTitle('Widget');
+ // Column M1 should be mappable to all 3, column M4 only to A and C
+ await toggleDrop(pickerDrop('M1'));
+ assert.deepEqual(await getOptions(), ['A', 'B', 'C']);
+ await toggleDrop(pickerDrop('M4'));
+ assert.deepEqual(await getOptions(), ['A', 'C']);
+ await toggleDrop(pickerDrop('M1'));
+ await clickOption('B');
+ assert.deepEqual(await widget.onRecordsMappings(), {...empty, M1: 'B'});
+ await revert();
+ });
+
+ it('should clear mappings on widget switch', async () => {
+ const revert = await gu.begin();
+
+ await toggleWidgetMenu();
+ await clickOption(COLUMN_WIDGET);
+ await accept();
+
+ // Make sure columns are there to pick.
+
+ // Visible column section is hidden.
+ assert.isFalse(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
+ // We should see a single Column picker.
+ assert.isTrue(await driver.find('.test-config-widget-label-for-Column').isPresent());
+
+ // Pick first column
+ await toggleDrop(pickerDrop('Column'));
+ await clickOption('A');
+ await gu.waitForServer();
+
+ // Now change to a widget without columns
+ await toggleWidgetMenu();
+ await clickOption(NORMAL_WIDGET);
+
+ // Picker should disappear and column mappings should be visible
+ assert.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
+ assert.isFalse(await driver.find('.test-config-widget-label-for-Column').isPresent());
+
+ await selectAccess(AccessLevel.read_table);
+ // Widget should receive full records.
+ assert.deepEqual(await widget.onRecords(), [
+ {id: 1, A: 'A'},
+ {id: 2, A: 'B'},
+ {id: 3, A: 'C'},
+ ]);
+ // Now go back to the widget with mappings.
+ await toggleWidgetMenu();
+ await clickOption(COLUMN_WIDGET);
+ await accept();
+ assert.equal(await driver.find(pickerDrop('Column')).getText(), 'Pick a column');
+ assert.isFalse(await driver.find('.test-vfc-visible-fields-select-all').isPresent());
+ assert.isTrue(await driver.find('.test-config-widget-label-for-Column').isPresent());
+ await revert();
+ });
+
+ it('should render multiple options', async () => {
+ const revert = await gu.begin();
+ await toggleWidgetMenu();
+ await clickOption(CUSTOM_URL);
+ await gu.setWidgetUrl(
+ createConfigUrl({
+ columns: [
+ {name: 'M1', allowMultiple: true},
+ {name: 'M2', type: 'Text', allowMultiple: true},
+ ],
+ requiredAccess: 'read table',
+ })
+ );
+ await accept();
+ const empty = {M1: [], M2: []};
+ await widget.waitForFrame();
+ // Add some columns, numeric B and any C.
+ await gu.selectSectionByTitle('Table');
+ await gu.addColumn('B');
+ await gu.getCell('B', 1).click();
+ await gu.enterCell('99');
+ await gu.addColumn('C');
+ await gu.selectSectionByTitle('Widget');
+ // Make sure we have no mappings
+ assert.deepEqual(await widget.onRecordsMappings(), null);
+ // Map all columns to M1
+ await click(pickerAdd('M1'));
+ assert.deepEqual(await getMenuOptions(), ['A', 'B', 'C']);
+ await clickMenuItem('A');
+ await click(pickerAdd('M1'));
+ await clickMenuItem('B');
+ await click(pickerAdd('M1'));
+ await clickMenuItem('C');
+ assert.deepEqual(await widget.onRecordsMappings(), {...empty, M1: ['A', 'B', 'C']});
+ // Map A and C to M2
+ await click(pickerAdd('M2'));
+ assert.deepEqual(await getMenuOptions(), ['A', 'C']);
+ // There should be information that column B is hidden (as it is not text)
+ assert.equal(await driver.find('.test-config-widget-map-message-M2').getText(), '1 non-text column is not shown');
+ await clickMenuItem('A');
+ await click(pickerAdd('M2'));
+ await clickMenuItem('C');
+ assert.deepEqual(await widget.onRecordsMappings(), {M1: ['A', 'B', 'C'], M2: ['A', 'C']});
+ function dragItem(column: string, item: string) {
+ return driver.findContent(`.test-config-widget-map-list-for-${column} .kf_draggable`, item);
+ }
+ // Should support reordering, reorder - move A after C
+ await driver.withActions(actions =>
+ actions
+ .move({origin: dragItem('M1', 'A')})
+ .move({origin: dragItem('M1', 'A').find('.test-dragger')})
+ .press()
+ .move({origin: dragItem('M1', 'C'), y: 1})
+ .release()
+ );
+ await gu.waitForServer();
+ assert.deepEqual(await widget.onRecordsMappings(), {M1: ['B', 'C', 'A'], M2: ['A', 'C']});
+ // Should support removing
+ const removeButton = (column: string, item: string) => {
+ return dragItem(column, item).mouseMove().find('.test-config-widget-ref-select-remove');
+ };
+ await removeButton('M1', 'B').click();
+ await gu.waitForServer();
+ assert.deepEqual(await widget.onRecordsMappings(), {M1: ['C', 'A'], M2: ['A', 'C']});
+ // Should undo removing
+ await gu.undo();
+ assert.deepEqual(await widget.onRecordsMappings(), {M1: ['B', 'C', 'A'], M2: ['A', 'C']});
+ await removeButton('M1', 'B').click();
+ await gu.waitForServer();
+ await removeButton('M1', 'C').click();
+ await gu.waitForServer();
+ await removeButton('M2', 'C').click();
+ await gu.waitForServer();
+ assert.deepEqual(await widget.onRecordsMappings(), {M1: ['A'], M2: ['A']});
+ await revert();
+ });
+
+ 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(
+ createConfigUrl({
+ columns: [{name: 'M1'}, {name: 'M2', allowMultiple: true}],
+ requiredAccess: 'read table',
+ })
+ );
+ await accept();
+ await widget.waitForFrame();
+ // Add some columns, to remove later
+ await gu.selectSectionByTitle('Table');
+ await gu.addColumn('B');
+ await gu.addColumn('C');
+ await gu.selectSectionByTitle('Widget');
+ // Make sure we have no mappings
+ assert.deepEqual(await widget.onRecordsMappings(), null);
+ // Map B to M1
+ await toggleDrop(pickerDrop('M1'));
+ await clickOption('B');
+ // Map all columns to M2
+ for (const col of ['A', 'B', 'C']) {
+ await click(pickerAdd('M2'));
+ await clickMenuItem(col);
+ }
+ assert.deepEqual(await widget.onRecordsMappings(), {M1: 'B', M2: ['A', 'B', 'C']});
+ assert.deepEqual(await widget.onRecords(), [
+ {id: 1, B: null, A: 'A', C: null},
+ {id: 2, B: null, A: 'B', C: null},
+ {id: 3, B: null, A: 'C', C: null},
+ ]);
+ const removeColumn = async (col: string) => {
+ await gu.selectSectionByTitle('Table');
+ await gu.openColumnMenu(col, 'Delete column');
+ await gu.waitForServer();
+ await gu.selectSectionByTitle('Widget');
+ };
+ // Remove B column
+ await removeColumn('B');
+ // Mappings should be updated
+ assert.deepEqual(await widget.onRecordsMappings(), {M1: null, M2: ['A', 'C']});
+ // Records should not have B column
+ assert.deepEqual(await widget.onRecords(), [
+ {id: 1, A: 'A', C: null},
+ {id: 2, A: 'B', C: null},
+ {id: 3, A: 'C', C: null},
+ ]);
+ // Should be able to add B once more
+
+ // Add B as a new column
+ await gu.selectSectionByTitle('Table');
+ await gu.addColumn('B');
+ await gu.selectSectionByTitle('Widget');
+ // Adding the same column should not add it to mappings or records (as this is a new Id)
+ assert.deepEqual(await widget.onRecordsMappings(), {M1: null, M2: ['A', 'C']});
+ assert.deepEqual(await widget.onRecords(), [
+ {id: 1, A: 'A', C: null},
+ {id: 2, A: 'B', C: null},
+ {id: 3, A: 'C', C: null},
+ ]);
+
+ // 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']);
+ await clickOption('B');
+ await click(pickerAdd('M2'));
+ assert.deepEqual(await getMenuOptions(), ['B']); // multiple selection will only show not selected columns
+ await clickMenuItem('B');
+ assert.deepEqual(await widget.onRecordsMappings(), {M1: 'B', M2: ['A', 'C', 'B']});
+ assert.deepEqual(await widget.onRecords(), [
+ {id: 1, B: null, A: 'A', C: null},
+ {id: 2, B: null, A: 'B', C: null},
+ {id: 3, B: null, A: 'C', C: null},
+ ]);
+ await revert();
+ });
+
+ 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(
+ createConfigUrl({
+ columns: [{name: 'M1', type: 'Text'}, {name: 'M2', type: 'Text', allowMultiple: true}],
+ requiredAccess: 'read table',
+ })
+ );
+ await accept();
+ await widget.waitForFrame();
+ assert.deepEqual(await widget.onRecordsMappings(), null);
+ assert.deepEqual(await widget.onRecords(), [
+ {id: 1, A: 'A'},
+ {id: 2, A: 'B'},
+ {id: 3, A: 'C'},
+ ]);
+ await toggleDrop(pickerDrop("M1"));
+ await clickOption("A");
+ await click(pickerAdd("M2"));
+ await clickMenuItem("A");
+ assert.equal(await driver.find(pickerDrop("M1")).getText(), "A");
+ assert.deepEqual(await getListItems("M2"), ["A"]);
+ assert.deepEqual(await widget.onRecordsMappings(), {M1: 'A', M2: ["A"]});
+ assert.deepEqual(await widget.onRecords(), [
+ {id: 1, A: 'A'},
+ {id: 2, A: 'B'},
+ {id: 3, A: 'C'},
+ ]);
+ // Change column type to numeric
+ await gu.selectSectionByTitle('Table');
+ await gu.getCell("A", 1).click();
+ await gu.setType(/Numeric/);
+ await gu.selectSectionByTitle('Widget');
+ await driver.find(".test-right-tab-pagewidget").click();
+ // Drop should be empty,
+ assert.equal(await driver.find(pickerDrop("M1")).getText(), "Pick a text column");
+ assert.isEmpty(await getListItems("M2"));
+ // with no options
+ await toggleDrop(pickerDrop("M1"));
+ assert.isEmpty(await getOptions());
+ await gu.sendKeys(Key.ESCAPE);
+ // The same for M2
+ await click(pickerAdd("M2"));
+ assert.isEmpty(await getMenuOptions());
+ assert.deepEqual(await widget.onRecordsMappings(), {M1: null, M2: []});
+ assert.deepEqual(await widget.onRecords(), [
+ {id: 1},
+ {id: 2},
+ {id: 3},
+ ]);
+ await revert();
+ });
+
+ it('should not display options on grid, card, card list, chart', async () => {
+ // Add Empty Grid
+ await gu.addNewSection(/Table/, /Table1/);
+ assert.isFalse(await hasSectionOption());
+ await gu.undo();
+
+ // Add Card view
+ await gu.addNewSection(/Card/, /Table1/);
+ assert.isFalse(await hasSectionOption());
+ await gu.undo();
+
+ // Add Card List view
+ await gu.addNewSection(/Card List/, /Table1/);
+ assert.isFalse(await hasSectionOption());
+ await gu.undo();
+
+ // Add Card List view
+ await gu.addNewSection(/Chart/, /Table1/);
+ assert.isFalse(await hasSectionOption());
+ await gu.undo();
+
+ // Add Custom - no section option by default
+ await gu.addNewSection(/Custom/, /Table1/);
+ assert.isFalse(await hasSectionOption());
+ await toggleWidgetMenu();
+ await clickOption(TESTER_WIDGET);
+ assert.isTrue(await hasSectionOption());
+ await gu.undo(2);
+ });
+
+ it('should indicate current state', async () => {
+ // Save button is available under Filter/Sort menu.
+ // For this custom widget it has four states:
+ // - Empty: no options are saved
+ // - Modified: options were set but are not saved yet
+ // - Customized: options are saved
+ // - Empty not saved: options are cleared but not saved
+ // This test test all the available transitions between those four states
+
+ const options = {test: 1} as const;
+ const options2 = {test: 2} as const;
+ // From the start we should be in empty state
+ await checkSortMenu('empty');
+ // Make modification
+ await widget.setOptions(options);
+ // State should be modified
+ await checkSortMenu('modified');
+ assert.deepEqual(await widget.onOptions(), options);
+ // Revert, should end up with empty state.
+ await revertMenu();
+ await checkSortMenu('empty');
+ assert.equal(await widget.onOptions(), null);
+
+ // Update once again and save.
+ await widget.setOptions(options);
+ await saveMenu();
+ await checkSortMenu('customized');
+ // Now test if undo works.
+ await gu.undo();
+ await checkSortMenu('empty');
+ assert.equal(await widget.onOptions(), null);
+
+ // Update once again and save.
+ await widget.setOptions(options);
+ await saveMenu();
+ // Modify and check the state - should be modified
+ await widget.setOptions(options2);
+ await checkSortMenu('modified');
+ assert.deepEqual(await widget.onOptions(), options2);
+ await saveMenu();
+
+ // Now clear options.
+ await clearOptions();
+ await checkSortMenu('emptyNotSaved');
+ assert.equal(await widget.onOptions(), null);
+ // And revert
+ await revertMenu();
+ await checkSortMenu('customized');
+ assert.deepEqual(await widget.onOptions(), options2);
+ // Clear once again and save.
+ await clearOptions();
+ await saveMenu();
+ assert.equal(await widget.onOptions(), null);
+ await checkSortMenu('empty');
+ // And check if undo goes to customized
+ await gu.undo();
+ await checkSortMenu('customized');
+ assert.deepEqual(await widget.onOptions(), options2);
+ });
+
+ for (const access of ['none', 'read table', 'full'] as const) {
+ describe(`with ${access} access`, function () {
+ before(function () {
+ if (server.isExternalServer()) {
+ this.skip();
+ }
+ });
+ it(`should get null options`, async () => {
+ await selectAccess(access);
+ await widget.waitForFrame();
+ assert.equal(await widget.onOptions(), null);
+ assert.equal(await widget.access(), access);
+ assert.isFalse(await widget.readonly());
+ });
+
+ it(`should save config options and inform about it the main widget`, async () => {
+ await selectAccess(access);
+ await widget.waitForFrame();
+ // Save config and check if normal widget received new configuration
+ const config = {key: 1} as const;
+ // save options through config,
+ await widget.setOptions(config);
+ // make sure custom widget got options,
+ assert.deepEqual(await widget.onOptions(), config);
+ await persistOptions();
+ // and make sure it will get it once again,
+ await refresh();
+ assert.deepEqual(await widget.onOptions(), config);
+ // and can read it on demand
+ assert.deepEqual(await widget.getOptions(), config);
+ });
+
+ it(`should save and read options`, async () => {
+ await selectAccess(access);
+ await widget.waitForFrame();
+ // Make sure get options returns null.
+ assert.equal(await widget.getOptions(), null);
+ // Invoke setOptions, should return undefined (no error).
+ assert.equal(await widget.setOptions({key: 'any'}), null);
+ // Once again get options, and see if it was saved.
+ assert.deepEqual(await widget.getOptions(), {key: 'any'});
+ await widget.clearOptions();
+ });
+
+ it(`should save and read options by keys`, async () => {
+ await selectAccess(access);
+ await widget.waitForFrame();
+ // Should support key operations
+ const set = async (key: string, value: any) => {
+ assert.equal(await widget.setOption(key, value), undefined);
+ assert.deepEqual(await widget.getOption(key), value);
+ };
+ await set('one', 1);
+ await set('two', 2);
+ assert.deepEqual(await widget.getOptions(), {one: 1, two: 2});
+ const json = {n: null, json: {value: [1, {val: 'a', bool: true}]}};
+ await set('json', json);
+ assert.equal(await widget.clearOptions(), undefined);
+ assert.equal(await widget.getOptions(), null);
+ await set('one', 1);
+ assert.equal(await widget.setOptions({key: 'any'}), undefined);
+ assert.deepEqual(await widget.getOptions(), {key: 'any'});
+ await widget.clearOptions();
+ });
+
+ it(`should call configure method`, async () => {
+ await selectAccess(access);
+ await widget.waitForFrame();
+ // Make sure configure wasn't called yet.
+ assert.isFalse(await widget.wasConfigureCalled());
+ // Open configuration through the creator panel
+ await driver.find('.test-config-widget-open-configuration').click();
+ assert.isTrue(await widget.wasConfigureCalled());
+
+ // Refresh, and call through the menu.
+ await refresh();
+ await gu.waitForDocToLoad();
+ await widget.waitForFrame();
+ // Make sure configure wasn't called yet.
+ assert.isFalse(await widget.wasConfigureCalled());
+ // Click through the menu.
+ const menu = await gu.openSectionMenu('viewLayout', 'Widget');
+ await menu.find('.test-section-open-configuration').click();
+ assert.isTrue(await widget.wasConfigureCalled());
+ });
+ });
+ }
+
+ it('should show options action button', async () => {
+ // Select widget without options
+ await toggleWidgetMenu();
+ await clickOption(NORMAL_WIDGET);
+ assert.isFalse(await hasSectionOption());
+ // Select widget with options
+ await toggleWidgetMenu();
+ await clickOption(TESTER_WIDGET);
+ assert.isTrue(await hasSectionOption());
+ // Select widget without options
+ await toggleWidgetMenu();
+ await clickOption(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);
+ assert.isFalse(await hasPrompt());
+ assert.equal(await givenAccess(), AccessLevel.none);
+ assert.equal(await widget.access(), AccessLevel.none);
+ // Select widget that requests read access.
+ await toggleWidgetMenu();
+ await clickOption(READ_WIDGET);
+ assert.isTrue(await hasPrompt());
+ assert.equal(await givenAccess(), AccessLevel.none);
+ assert.equal(await widget.access(), AccessLevel.none);
+ await accept();
+ assert.equal(await givenAccess(), AccessLevel.read_table);
+ assert.equal(await widget.access(), AccessLevel.read_table);
+ // Select widget that requests full access.
+ await toggleWidgetMenu();
+ await clickOption(FULL_WIDGET);
+ assert.isTrue(await hasPrompt());
+ assert.equal(await givenAccess(), AccessLevel.none);
+ assert.equal(await widget.access(), AccessLevel.none);
+ await accept();
+ assert.equal(await givenAccess(), AccessLevel.full);
+ assert.equal(await widget.access(), AccessLevel.full);
+ await gu.undo(5);
+ });
+
+ it('should pass readonly mode to custom widget', async () => {
+ const api = mainSession.createHomeApi();
+ await api.updateDocPermissions(docId, {users: {'support@getgrist.com': 'viewers'}});
+
+ const viewer = await gu.session().user('support').login();
+ await viewer.loadDoc(`/doc/${docId}`);
+
+ // Make sure that widget knows about readonly mode.
+ assert.isTrue(await widget.readonly());
+
+ // Log back
+ await mainSession.login();
+ await mainSession.loadDoc(`/doc/${docId}`);
+ await refresh();
+ });
+});
diff --git a/test/nbrowser/customUtil.ts b/test/nbrowser/customUtil.ts
new file mode 100644
index 00000000..becdf15a
--- /dev/null
+++ b/test/nbrowser/customUtil.ts
@@ -0,0 +1,12 @@
+export * from 'test/server/customUtil';
+import {driver} from "mocha-webdriver";
+
+export async function setAccess(option: "none"|"read table"|"full") {
+ const text = {
+ "none" : "No document access",
+ "read table": "Read selected table",
+ "full": "Full document access"
+ };
+ await driver.find(`.test-config-widget-access .test-select-open`).click();
+ await driver.findContent(`.test-select-menu li`, text[option]).click();
+}
diff --git a/yarn.lock b/yarn.lock
index e26c0019..76e0b4a5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1859,7 +1859,7 @@ commander@9.3.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-9.3.0.tgz#f619114a5a2d2054e0d9ff1b31d5ccf89255e26b"
integrity sha512-hv95iU5uXPbK83mjrJKuZyFM/LBAoCV/XhVGkS5Je6tl7sxr6A0ITMw5WoRV46/UaJ46Nllm3Xt7IaJhXTIkzw==
-commander@^2.11.0, commander@^2.20.0:
+commander@^2.11.0, commander@^2.12.2, commander@^2.20.0:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
@@ -3016,7 +3016,7 @@ fs-extra@7.0.0:
jsonfile "^4.0.0"
universalify "^0.1.0"
-fs-extra@^4.0.1, fs-extra@^4.0.2:
+fs-extra@^4.0.1, fs-extra@^4.0.2, fs-extra@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94"
integrity sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==
@@ -6736,6 +6736,16 @@ tr46@^1.0.1:
resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9"
integrity sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=
+ts-interface-builder@0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/ts-interface-builder/-/ts-interface-builder-0.3.2.tgz#664f7f4d2bd0079950ba6bb7cd2780262009a68f"
+ integrity sha512-8LcB+qSwnDzBeP47Nug2+4NUjdRNJ94MfzLNXQ4mmAM8UidDDQS0YoD7Ng6XONa8rX6nJenlgph1X459VYqypQ==
+ dependencies:
+ commander "^2.12.2"
+ fs-extra "^4.0.3"
+ glob "^7.1.6"
+ typescript "^3.0.0"
+
ts-interface-checker@1.0.2, ts-interface-checker@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-1.0.2.tgz#63f73a098b0ed34b982df1f490c54890e8e5e0b3"
@@ -6838,6 +6848,11 @@ typescript@4.7.4:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
+typescript@^3.0.0:
+ version "3.9.10"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8"
+ integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==
+
uglify-js@^3.1.4:
version "3.16.3"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.16.3.tgz#94c7a63337ee31227a18d03b8a3041c210fd1f1d"