mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(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
This commit is contained in:
		
							parent
							
								
									ec157dc469
								
							
						
					
					
						commit
						2438a63255
					
				
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										27
									
								
								buildtools/update_type_info.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										27
									
								
								buildtools/update_type_info.sh
									
									
									
									
									
										Executable file
									
								
							@ -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
 | 
			
		||||
							
								
								
									
										44
									
								
								buildtools/webpack.api.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								buildtools/webpack.api.config.js
									
									
									
									
									
										Normal file
									
								
							@ -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",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								test/fixtures/docs/CustomWidget.grist
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								test/fixtures/docs/CustomWidget.grist
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								test/fixtures/docs/TypeEncoding.grist
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								test/fixtures/docs/TypeEncoding.grist
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										43
									
								
								test/fixtures/sites/config/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								test/fixtures/sites/config/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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>
 | 
			
		||||
							
								
								
									
										72
									
								
								test/fixtures/sites/config/page.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								test/fixtures/sites/config/page.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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');
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										16
									
								
								test/fixtures/sites/embed/embed.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								test/fixtures/sites/embed/embed.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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>
 | 
			
		||||
							
								
								
									
										12
									
								
								test/fixtures/sites/filter/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								test/fixtures/sites/filter/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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>
 | 
			
		||||
							
								
								
									
										13
									
								
								test/fixtures/sites/filter/page.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								test/fixtures/sites/filter/page.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
							
								
								
									
										5
									
								
								test/fixtures/sites/hello/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								test/fixtures/sites/hello/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
			
		||||
<html>
 | 
			
		||||
  <body>
 | 
			
		||||
    <h1 id="hello-title">Hello World</h1>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										27
									
								
								test/fixtures/sites/paste/paste.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								test/fixtures/sites/paste/paste.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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>
 | 
			
		||||
							
								
								
									
										11
									
								
								test/fixtures/sites/probe/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								test/fixtures/sites/probe/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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>
 | 
			
		||||
							
								
								
									
										20
									
								
								test/fixtures/sites/probe/page.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								test/fixtures/sites/probe/page.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
							
								
								
									
										21
									
								
								test/fixtures/sites/readout/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								test/fixtures/sites/readout/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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>
 | 
			
		||||
							
								
								
									
										37
									
								
								test/fixtures/sites/readout/page.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								test/fixtures/sites/readout/page.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
							
								
								
									
										16
									
								
								test/fixtures/sites/types/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								test/fixtures/sites/types/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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>
 | 
			
		||||
							
								
								
									
										42
									
								
								test/fixtures/sites/types/page.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								test/fixtures/sites/types/page.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
							
								
								
									
										11
									
								
								test/fixtures/sites/zap/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								test/fixtures/sites/zap/index.html
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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>
 | 
			
		||||
							
								
								
									
										56
									
								
								test/fixtures/sites/zap/page.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								test/fixtures/sites/zap/page.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
							
								
								
									
										478
									
								
								test/nbrowser/CustomView.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										478
									
								
								test/nbrowser/CustomView.ts
									
									
									
									
									
										Normal file
									
								
							@ -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');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										583
									
								
								test/nbrowser/CustomWidgets.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										583
									
								
								test/nbrowser/CustomWidgets.ts
									
									
									
									
									
										Normal file
									
								
							@ -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());
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										952
									
								
								test/nbrowser/CustomWidgetsConfig.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										952
									
								
								test/nbrowser/CustomWidgetsConfig.ts
									
									
									
									
									
										Normal file
									
								
							@ -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();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										12
									
								
								test/nbrowser/customUtil.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								test/nbrowser/customUtil.ts
									
									
									
									
									
										Normal file
									
								
							@ -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();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										19
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								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"
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user