(core) Moving widget tests to core

Summary:
- Custom widget tests are now in grist-core
- Adding buildtools for grist-plugin-api.js

Test Plan: Existing tests

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3617
pull/20/head
Jarosław Sadziński 2 years ago
parent ec157dc469
commit 2438a63255

1
.gitignore vendored

@ -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

@ -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

@ -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

@ -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/
},
]
}
};

@ -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",

Binary file not shown.

Binary file not shown.

@ -0,0 +1,43 @@
<html>
<head>
<meta charset="utf-8" />
<script src="/grist-plugin-api.js"></script>
<script src="page.js"></script>
</head>
<body style="background: white;">
<div id="ready"></div>
<div id="access"></div>
<div id="readonly"></div>
<div>onOptions event data:</div>
<pre id="onOptions"></pre>
<div>onRecord event data:</div>
<pre id="onRecord"></pre>
<div>onRecord mapping data:</div>
<pre id="onRecordMappings"></pre>
<div>onRecords event data:</div>
<pre id="onRecords"></pre>
<div>onRecord mappings data:</div>
<pre id="onRecordsMappings"></pre>
<div>configure handler:</div>
<pre id="configure"></pre>
<div>Method input json:</div>
<input type="text" id="input" value="" />
<div>Method output json:</div>
<div id="output"></div>
<div>Methods:</div>
<button onclick="getOptions()">getOptions</button>
<button onclick="setOptions()">setOptions</button>
<button onclick="getOption()">getOption</button>
<button onclick="setOption()">setOption</button>
<button onclick="mappings()">mappings</button>
<button onclick="configure()">configure</button>
<button onclick="clearOptions()">clearOptions</button>
</body>
</html>

@ -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');
};

@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
html, body { height: 100%; }
#outside { display: block; height: 15%; width: 100%; }
#embed { height: 70%; width: 100%; }
</style>
</head>
<body>
<h3>Embed Grist</h3>
<textarea id='outside'></textarea>
<iframe id='embed' src=""></iframe>
</body>
</html>

@ -0,0 +1,12 @@
<html>
<head>
<meta charset="utf-8" />
<script src="/grist-plugin-api.js"></script>
<script src="page.js"></script>
</head>
<body>
<h1>Filter</h1>
<p>Enter row ids (ie: "1" or "1, 3, 4"): </p>
<input type="text" id="rowIds"/>
</body>
</html>

@ -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;

@ -0,0 +1,5 @@
<html>
<body>
<h1 id="hello-title">Hello World</h1>
</body>
</html>

@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
</head>
<body>
<table cellspacing="0" cellpadding="0" border="1">
<colgroup><col span="2"></colgroup>
<tbody>
<tr>
<td>a</td>
<td>b</td>
</tr>
<tr>
<td colspan="2">c</td>
</tr>
<tr>
<td>d</td>
<td rowspan="2">e</td>
</tr>
<tr>
<td>f</td>
</tr>
</tbody>
</table>
</body>
</html>

@ -0,0 +1,11 @@
<html>
<head>
<meta charset="utf-8" />
<script src="/grist-plugin-api.js"></script>
<script src="page.js"></script>
</head>
<body>
<h1>Probe</h1>
<div id="placeholder"></div>
</body>
</html>

@ -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 = '<div id="output">no joy</div>';
}, 1000);
api.listTables()
.then(tables => {
clearTimeout(fallback);
placeholder.innerHTML = `<div id="output">${JSON.stringify(tables)}</div>`;
});
}
window.onload = readDoc;

@ -0,0 +1,21 @@
<html>
<head>
<meta charset="utf-8" />
<script src="/grist-plugin-api.js"></script>
<script src="page.js"></script>
</head>
<body>
<h1>Readout</h1>
<h2>placeholder</h2>
<div id="placeholder"></div>
<h2>rowId</h2>
<div id="rowId"></div>
<h2>tableId</h2>
<div id="tableId"></div>
<hr />
<h2>record</h2>
<div id="record"></div>
<h2>records</h2>
<div id="records"></div>
</body>
</html>

@ -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 = '<div id="output">no joy</div>';
}, 1000);
fetchTable
.then(table => {
clearTimeout(fallback);
placeholder.innerHTML = `<div id="output">${JSON.stringify(table)}</div>`;
});
}
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;

@ -0,0 +1,16 @@
<html>
<head>
<meta charset="utf-8" />
<script src="/grist-plugin-api.js"></script>
<script src="page.js"></script>
</head>
<body>
<h1>Types</h1>
<div>
onRecord() matches a record in table?
<div id="match"></div>
</div>
<h2>record</h2>
<pre id="record"></pre>
</body>
</html>

@ -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;

@ -0,0 +1,11 @@
<html>
<head>
<meta charset="utf-8" />
<script src="/grist-plugin-api.js"></script>
<script src="page.js"></script>
</head>
<body>
<h1>Zap</h1>
<div id="placeholder"></div>
</body>
</html>

@ -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;

@ -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');
});
});

@ -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('<html><head><script src="/grist-plugin-api.js"></script></head><body>\n' +
(req.query.name || req.query.access) + // send back widget name from query string or access level
'</body></html>\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<any>,
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());
});
});

@ -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 : <<Error.message>> }
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();
});
});

@ -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();
}

@ -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"

Loading…
Cancel
Save