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 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
ab
c
de
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"