From 4e67c679b21400de3ea5887c95e86ed2ec4ceba8 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 26 Oct 2023 23:44:47 +0200 Subject: [PATCH] (core) Options for plugin API functions which fetch data from the selected table or record Summary: Adds a new interface `FetchSelectedOptions` with three keys (including the preexisting `keepEncoded`) and adds/updates an optional `options: FetchSelectedOptions` to six related functions which fetch data from the selected table or record. The `keepEncoded` and `format` options have different default values for different methods for backwards compatibility, but otherwise the different methods now have much more similar behaviour. The new `includeColumns` option allows fetching all columns which was previously only possible using `docApi.fetchTable` (which wasn't always a great alternative) but this requires full access to avoid exposing more data than before and violating user expectations. Eventually, similar options should be added to `docApi.fetchTable` to make the API even more consistent. Discussion: https://grist.slack.com/archives/C0234CPPXPA/p1696510548994899 Test Plan: Added a new nbrowser test with a corresponding fixture site and document, showing how the functions have different default option values but are all configurable now. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4077 --- app/client/components/CustomView.ts | 2 +- app/client/components/WidgetFrame.ts | 49 +++++--- app/plugin/GristAPI-ti.ts | 11 +- app/plugin/GristAPI.ts | 43 ++++++- app/plugin/grist-plugin-api.ts | 100 ++++++++++----- test/fixtures/docs/FetchSelectedOptions.grist | Bin 0 -> 163840 bytes .../sites/fetchSelectedOptions/index.html | 11 ++ .../sites/fetchSelectedOptions/page.js | 101 +++++++++++++++ test/nbrowser/CustomView.ts | 117 ++++++++++++++++++ 9 files changed, 379 insertions(+), 55 deletions(-) create mode 100644 test/fixtures/docs/FetchSelectedOptions.grist create mode 100644 test/fixtures/sites/fetchSelectedOptions/index.html create mode 100644 test/fixtures/sites/fetchSelectedOptions/page.js diff --git a/app/client/components/CustomView.ts b/app/client/components/CustomView.ts index 953edb14..5431b26e 100644 --- a/app/client/components/CustomView.ts +++ b/app/client/components/CustomView.ts @@ -239,7 +239,7 @@ export class CustomView extends Disposable { GristDocAPIImpl.defaultAccess); frame.exposeAPI( "GristView", - new GristViewImpl(view), new MinimumLevel(AccessLevel.read_table)); + new GristViewImpl(view, access), new MinimumLevel(AccessLevel.read_table)); frame.exposeAPI( "CustomSectionAPI", new CustomSectionAPIImpl( diff --git a/app/client/components/WidgetFrame.ts b/app/client/components/WidgetFrame.ts index 44944759..c7297773 100644 --- a/app/client/components/WidgetFrame.ts +++ b/app/client/components/WidgetFrame.ts @@ -11,8 +11,10 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions'; import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes'; import {Theme} from 'app/common/ThemePrefs'; -import {AccessTokenOptions, CursorPos, CustomSectionAPI, GristDocAPI, GristView, - InteractionOptionsRequest, WidgetAPI, WidgetColumnMap} from 'app/plugin/grist-plugin-api'; +import { + AccessTokenOptions, CursorPos, CustomSectionAPI, FetchSelectedOptions, GristDocAPI, GristView, + InteractionOptionsRequest, WidgetAPI, WidgetColumnMap +} from 'app/plugin/grist-plugin-api'; import {MsgType, Rpc} from 'grain-rpc'; import {Computed, Disposable, dom, Observable} from 'grainjs'; import noop = require('lodash/noop'); @@ -374,13 +376,14 @@ export class GristDocAPIImpl implements GristDocAPI { * GristViewAPI implemented over BaseView. */ export class GristViewImpl implements GristView { - constructor(private _baseView: BaseView) {} + constructor(private _baseView: BaseView, private _access: AccessLevel) { + } - public async fetchSelectedTable(): Promise { + public async fetchSelectedTable(options: FetchSelectedOptions = {}): Promise { // If widget has a custom columns mapping, we will ignore hidden columns section. // Hidden/Visible columns will eventually reflect what is available, but this operation // is not instant - and widget can receive rows with fields that are not in the mapping. - const columns: ColumnRec[] = this._visibleColumns(); + const columns: ColumnRec[] = this._visibleColumns(options); const rowIds = this._baseView.sortedRows.getKoArray().peek().filter(id => id != 'new'); const data: BulkColValues = {}; for (const column of columns) { @@ -394,13 +397,13 @@ export class GristViewImpl implements GristView { return data; } - public async fetchSelectedRecord(rowId: number): Promise { + public async fetchSelectedRecord(rowId: number, options: FetchSelectedOptions = {}): Promise { // Prepare an object containing the fields available to the view // for the specified row. A RECORD()-generated rendering would be // more useful. but the data engine needs to know what information // the custom view depends on, so we shouldn't volunteer any untracked // information here. - const columns: ColumnRec[] = this._visibleColumns(); + const columns: ColumnRec[] = this._visibleColumns(options); const data: RowRecord = {id: rowId}; for (const column of columns) { const colId: string = column.displayColModel.peek().colId.peek(); @@ -434,16 +437,32 @@ export class GristViewImpl implements GristView { return Promise.resolve(); } - private _visibleColumns() { + private _visibleColumns(options: FetchSelectedOptions): ColumnRec[] { const columns: ColumnRec[] = this._baseView.viewSection.columns.peek(); - const hiddenCols = this._baseView.viewSection.hiddenColumns.peek().map(c => c.id.peek()); - const mappings = this._baseView.viewSection.mappedColumns.peek(); - const mappedColumns = new Set(flatMap(Object.values(mappings || {}))); - const notHidden = (col: ColumnRec) => !hiddenCols.includes(col.id.peek()); - const mapped = (col: ColumnRec) => mappings && mappedColumns.has(col.colId.peek()); // If columns are mapped, return only those that are mapped. - // Otherwise return all not hidden columns; - return mappings ? columns.filter(mapped) : columns.filter(notHidden); + const mappings = this._baseView.viewSection.mappedColumns.peek(); + if (mappings) { + const mappedColumns = new Set(flatMap(Object.values(mappings))); + const mapped = (col: ColumnRec) => mappedColumns.has(col.colId.peek()); + return columns.filter(mapped); + } else if (options.includeColumns === 'shown' || !options.includeColumns) { + // Return columns that have been shown by the user, i.e. have a corresponding view field. + const hiddenCols = this._baseView.viewSection.hiddenColumns.peek().map(c => c.id.peek()); + const notHidden = (col: ColumnRec) => !hiddenCols.includes(col.id.peek()); + return columns.filter(notHidden); + } + // These options are newer and expose more data than the user may have intended, + // so they require full access. + if (this._access !== AccessLevel.full) { + throwError(this._access); + } + if (options.includeColumns === 'normal') { + // Return all 'normal' columns of the table, regardless of whether the user has shown them. + return columns; + } else { + // Return *all* columns, including special invisible columns like manualSort. + return this._baseView.viewSection.table.peek().columns.peek().all(); + } } } diff --git a/app/plugin/GristAPI-ti.ts b/app/plugin/GristAPI-ti.ts index e05717eb..24168dac 100644 --- a/app/plugin/GristAPI-ti.ts +++ b/app/plugin/GristAPI-ti.ts @@ -30,9 +30,15 @@ export const GristDocAPI = t.iface([], { "getAccessToken": t.func("AccessTokenResult", t.param("options", "AccessTokenOptions")), }); +export const FetchSelectedOptions = t.iface([], { + "keepEncoded": t.opt("boolean"), + "format": t.opt(t.union(t.lit('rows'), t.lit('columns'))), + "includeColumns": t.opt(t.union(t.lit('shown'), t.lit('normal'), t.lit('all'))), +}); + export const GristView = t.iface([], { - "fetchSelectedTable": t.func("any"), - "fetchSelectedRecord": t.func("any", t.param("rowId", "number")), + "fetchSelectedTable": t.func("any", t.param("options", "FetchSelectedOptions", true)), + "fetchSelectedRecord": t.func("any", t.param("rowId", "number"), t.param("options", "FetchSelectedOptions", true)), "allowSelectBy": t.func("void"), "setSelectedRows": t.func("void", t.param("rowIds", t.union(t.array("number"), "null"))), "setCursorPos": t.func("void", t.param("pos", "CursorPos")), @@ -54,6 +60,7 @@ const exportedTypeSuite: t.ITypeSuite = { ComponentKind, GristAPI, GristDocAPI, + FetchSelectedOptions, GristView, AccessTokenOptions, AccessTokenResult, diff --git a/app/plugin/GristAPI.ts b/app/plugin/GristAPI.ts index 9f7a12da..8962ea11 100644 --- a/app/plugin/GristAPI.ts +++ b/app/plugin/GristAPI.ts @@ -134,21 +134,54 @@ export interface GristDocAPI { getAccessToken(options: AccessTokenOptions): Promise; } +/** + * Options for functions which fetch data from the selected table or record: + * + * - [[onRecords]] + * - [[onRecord]] + * - [[fetchSelectedRecord]] + * - [[fetchSelectedTable]] + * - [[GristView.fetchSelectedRecord]] + * - [[GristView.fetchSelectedTable]] + * + * The different methods have different default values for `keepEncoded` and `format`. + **/ +export interface FetchSelectedOptions { + /** + * - `true`: the returned data will contain raw `CellValue`s. + * - `false`: the values will be decoded, replacing e.g. `['D', timestamp]` with a moment date. + */ + keepEncoded?: boolean; + + /** + * - `rows`, the returned data will be an array of objects, one per row, with column names as keys. + * - `columns`, the returned data will be an object with column names as keys, and arrays of values. + */ + format?: 'rows' | 'columns'; + + /** + * - `shown` (default): return only columns that are explicitly shown + * in the right panel configuration of the widget. This is the only value that doesn't require full access. + * - `normal`: return all 'normal' columns, regardless of whether the user has shown them. + * - `all`: also return special invisible columns like `manualSort` and display helper columns. + */ + includeColumns?: 'shown' | 'normal' | 'all'; +} + /** * Interface for the data backing a single widget. */ export interface GristView { /** * Like [[GristDocAPI.fetchTable]], but gets data for the custom section specifically, if there is any. + * By default, `options.keepEncoded` is `true` and `format` is `columns`. */ - fetchSelectedTable(): Promise; - // TODO: return type is Promise{[colId: string]: CellValue[]}> but cannot be specified - // because ts-interface-builder does not properly support index-signature. + fetchSelectedTable(options?: FetchSelectedOptions): Promise; /** - * Fetches selected record by its `rowId`. + * Fetches selected record by its `rowId`. By default, `options.keepEncoded` is `true`. */ - fetchSelectedRecord(rowId: number): Promise; + fetchSelectedRecord(rowId: number, options?: FetchSelectedOptions): Promise; // TODO: return type is Promise{[colId: string]: CellValue}> but cannot be specified // because ts-interface-builder does not properly support index-signature. diff --git a/app/plugin/grist-plugin-api.ts b/app/plugin/grist-plugin-api.ts index 0361b3b7..ac6e3ef5 100644 --- a/app/plugin/grist-plugin-api.ts +++ b/app/plugin/grist-plugin-api.ts @@ -20,8 +20,10 @@ import { ColumnsToMap, CustomSectionAPI, InteractionOptions, InteractionOptionsRequest, WidgetColumnMap } from './CustomSectionAPI'; -import { AccessTokenOptions, AccessTokenResult, GristAPI, GristDocAPI, - GristView, RPC_GRISTAPI_INTERFACE } from './GristAPI'; +import { + AccessTokenOptions, AccessTokenResult, FetchSelectedOptions, GristAPI, GristDocAPI, + GristView, RPC_GRISTAPI_INTERFACE +} from './GristAPI'; import { RowRecord } from './GristData'; import { ImportSource, ImportSourceAPI, InternalImportSourceAPI } from './InternalImportSourceAPI'; import { decodeObject, mapValues } from './objtypes'; @@ -53,7 +55,34 @@ export const coreDocApi = rpc.getStub('GristDocAPI@grist', checkers /** * Interface for the records backing a custom widget. */ -export const viewApi = rpc.getStub('GristView', checkers.GristView); +const viewApiStub = rpc.getStub('GristView', checkers.GristView); +export const viewApi: GristView = { + ...viewApiStub, + // Decoded objects aren't fully preserved over the RPC channel, so decoding has to happen on this side. + async fetchSelectedTable(options: FetchSelectedOptions = {}) { + let data = await viewApiStub.fetchSelectedTable(options); + if (options.keepEncoded === false) { + data = mapValues(data, (col) => col.map(decodeObject)); + } + if (options.format === 'rows') { + const rows: RowRecord[] = []; + for (let i = 0; i < data.id.length; i++) { + const row: RowRecord = {id: data.id[i]}; + for (const key of Object.keys(data)) { + row[key] = data[key][i]; + } + rows.push(row); + } + return rows; + } else { + return data; + } + }, + async fetchSelectedRecord(rowId: number, options: FetchSelectedOptions = {}) { + const rec = await viewApiStub.fetchSelectedRecord(rowId, options); + return options.keepEncoded === false ? mapValues(rec, decodeObject) : rec; + }, +}; /** * Interface for the state of a custom widget. @@ -84,25 +113,19 @@ export const setCursorPos = viewApi.setCursorPos; /** - * Fetches data backing the widget as for [[GristView.fetchSelectedTable]], - * but decoding data by default, replacing e.g. ['D', timestamp] with - * a moment date. Option `keepEncoded` skips the decoding step. + * Same as [[GristView.fetchSelectedTable]], but the option `keepEncoded` is `false` by default. */ - export async function fetchSelectedTable(options: {keepEncoded?: boolean} = {}) { - const table = await viewApi.fetchSelectedTable(); - return options.keepEncoded ? table : - mapValues(table, (col) => col.map(decodeObject)); +export async function fetchSelectedTable(options: FetchSelectedOptions = {}) { + options = {...options, keepEncoded: options.keepEncoded || false}; + return await viewApi.fetchSelectedTable(options); } /** - * Fetches current selected record as for [[GristView.fetchSelectedRecord]], - * but decoding data by default, replacing e.g. ['D', timestamp] with - * a moment date. Option `keepEncoded` skips the decoding step. + * Same as [[GristView.fetchSelectedRecord]], but the option `keepEncoded` is `false` by default. */ -export async function fetchSelectedRecord(rowId: number, options: {keepEncoded?: boolean} = {}) { - const rec = await viewApi.fetchSelectedRecord(rowId); - return options.keepEncoded ? rec : - mapValues(rec, decodeObject); +export async function fetchSelectedRecord(rowId: number, options: FetchSelectedOptions = {}) { + options = {...options, keepEncoded: options.keepEncoded || false}; + return await viewApi.fetchSelectedRecord(rowId, options); } @@ -342,18 +365,34 @@ export function mapColumnNamesBack(data: any, options?: { return mapColumnNames(data, {...options, reverse: true}); } +/** + * While `fetchSelected(Record|Table)` check the access level on 'the Grist side', + * `onRecord(s)` needs to check this in advance for the caller to be able to handle the error. + */ +function checkAccessLevelForColumns(options: FetchSelectedOptions) { + const accessLevel = new URL(window.location.href).searchParams.get("access"); + if (accessLevel !== "full" && options.includeColumns && options.includeColumns !== "shown") { + throw new Error("Access not granted. Current access level " + accessLevel); + } +} + /** * For custom widgets, add a handler that will be called whenever the * row with the cursor changes - either by switching to a different row, or * by some value within the row potentially changing. Handler may * in the future be called with null if the cursor moves away from * any row. + * By default, `options.keepEncoded` is `false`. */ -export function onRecord(callback: (data: RowRecord | null, mappings: WidgetColumnMap | null) => unknown) { +export function onRecord( + callback: (data: RowRecord | null, mappings: WidgetColumnMap | null) => unknown, + options: FetchSelectedOptions = {}, +) { + checkAccessLevelForColumns(options); // TODO: currently this will be called even if the content of a different row changes. on('message', async function(msg) { if (!msg.tableId || !msg.rowId || msg.rowId === 'new') { return; } - const rec = await docApi.fetchSelectedRecord(msg.rowId); + const rec = await docApi.fetchSelectedRecord(msg.rowId, options); callback(rec, await getMappingsIfChanged(msg)); }); } @@ -372,22 +411,19 @@ export function onNewRecord(callback: (mappings: WidgetColumnMap | null) => unkn /** * For custom widgets, add a handler that will be called whenever the - * selected records change. Handler will be called with a list of records. + * selected records change. + * By default, `options.format` is `'rows'` and `options.keepEncoded` is `false`. */ -export function onRecords(callback: (data: RowRecord[], mappings: WidgetColumnMap | null) => unknown) { +export function onRecords( + callback: (data: RowRecord[], mappings: WidgetColumnMap | null) => unknown, + options: FetchSelectedOptions = {}, +) { + checkAccessLevelForColumns(options); + options = {...options, format: options.format || 'rows'}; on('message', async function(msg) { if (!msg.tableId || !msg.dataChange) { return; } - const data = await docApi.fetchSelectedTable(); - if (!data.id) { return; } - const rows: RowRecord[] = []; - for (let i = 0; i < data.id.length; i++) { - const row: RowRecord = {id: data.id[i]}; - for (const key of Object.keys(data)) { - row[key] = data[key][i]; - } - rows.push(row); - } - callback(rows, await getMappingsIfChanged(msg)); + const data = await docApi.fetchSelectedTable(options); + callback(data, await getMappingsIfChanged(msg)); }); } diff --git a/test/fixtures/docs/FetchSelectedOptions.grist b/test/fixtures/docs/FetchSelectedOptions.grist new file mode 100644 index 0000000000000000000000000000000000000000..d1e1a744c435e3132513775933427796751c889b GIT binary patch literal 163840 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 + + + + + + +

FetchSelectedOptions

+

+  
+
diff --git a/test/fixtures/sites/fetchSelectedOptions/page.js b/test/fixtures/sites/fetchSelectedOptions/page.js
new file mode 100644
index 00000000..464b90cc
--- /dev/null
+++ b/test/fixtures/sites/fetchSelectedOptions/page.js
@@ -0,0 +1,101 @@
+/* global document, grist, window */
+
+function setup() {
+  const data = {
+    default: {},
+    options: {},
+  };
+  let showCount = 0;
+
+  function showData() {
+    showCount += 1;
+    if (showCount < 12) {
+      return;
+    }
+    document.getElementById('data').innerHTML = JSON.stringify(data, null, 2);
+  }
+
+  grist.onRecord(function (rec) {
+    data.default.onRecord = rec;
+    showData();
+  });
+  grist.onRecords(function (recs) {
+    data.default.onRecords = recs;
+    showData();
+  });
+  grist.fetchSelectedTable().then(function (table) {
+    data.default.fetchSelectedTable = table;
+    showData();
+  });
+  grist.fetchSelectedRecord(1).then(function (rec) {
+    data.default.fetchSelectedRecord = rec;
+    showData();
+  });
+  grist.viewApi.fetchSelectedTable().then(function (table) {
+    data.default.viewApiFetchSelectedTable = table;
+    showData();
+  });
+  grist.viewApi.fetchSelectedRecord(2).then(function (rec) {
+    data.default.viewApiFetchSelectedRecord = rec;
+    showData();
+  });
+
+  try {
+    grist.onRecord(function (rec) {
+      data.options.onRecord = rec;
+      showData();
+    }, {keepEncoded: true, includeColumns: 'normal', format: 'columns'});
+  } catch (e) {
+    data.options.onRecord = String(e);
+    showData();
+  }
+  try {
+    grist.onRecords(function (recs) {
+      data.options.onRecords = recs;
+      showData();
+    }, {keepEncoded: true, includeColumns: 'all', format: 'columns'});
+  } catch (e) {
+    data.options.onRecords = String(e);
+    showData();
+  }
+  grist.fetchSelectedTable(
+    {keepEncoded: true, includeColumns: 'all', format: 'rows'}
+  ).then(function (table) {
+    data.options.fetchSelectedTable = table;
+    showData();
+  }).catch(function (err) {
+    data.options.fetchSelectedTable = String(err);
+    showData();
+  });
+  grist.fetchSelectedRecord(1,
+    {keepEncoded: true, includeColumns: 'normal', format: 'rows'}
+  ).then(function (rec) {
+    data.options.fetchSelectedRecord = rec;
+    showData();
+  }).catch(function (err) {
+    data.options.fetchSelectedRecord = String(err);
+    showData();
+  });
+  grist.viewApi.fetchSelectedTable(
+    {keepEncoded: false, includeColumns: 'all', format: 'rows'}
+  ).then(function (table) {
+    data.options.viewApiFetchSelectedTable = table;
+    showData();
+  }).catch(function (err) {
+    data.options.viewApiFetchSelectedTable = String(err);
+    showData();
+  });
+  grist.viewApi.fetchSelectedRecord(2,
+    {keepEncoded: false, includeColumns: 'normal', format: 'rows'}
+  ).then(function (rec) {
+    data.options.viewApiFetchSelectedRecord = rec;
+    showData();
+  }).catch(function (err) {
+    data.options.viewApiFetchSelectedRecord = String(err);
+    showData();
+  });
+
+  grist.ready();
+}
+
+window.onload = setup;
diff --git a/test/nbrowser/CustomView.ts b/test/nbrowser/CustomView.ts
index 80bc28fc..a73d5bad 100644
--- a/test/nbrowser/CustomView.ts
+++ b/test/nbrowser/CustomView.ts
@@ -530,6 +530,123 @@ describe('CustomView', function() {
     const opinions = await api.getDocAPI(doc.id).getRows('Opinions');
     assert.equal(opinions['A'][0], 'do not zap plz');
   });
+
+  it('allows custom options for fetching data', async function () {
+    const mainSession = await gu.session().teamSite.login();
+    const doc = await mainSession.tempDoc(cleanup, 'FetchSelectedOptions.grist', {load: false});
+    await mainSession.loadDoc(`/doc/${doc.id}`);
+
+    await gu.toggleSidePanel('right', 'open');
+    await gu.getSection('TABLE1 Custom').click();
+    await driver.find('.test-config-widget-url').click();
+    await gu.sendKeys(`${serving.url}/fetchSelectedOptions`, Key.ENTER);
+    await gu.waitForServer();
+
+    const expected = {
+      "default": {
+        "fetchSelectedTable": {
+          "id": [1, 2],
+          "A": [["a", "b"], ["c", "d"]],
+        },
+        "fetchSelectedRecord": {
+          "id": 1,
+          "A": ["a", "b"]
+        },
+        // The viewApi methods don't decode data by default, hence the "L" prefixes.
+        "viewApiFetchSelectedTable": {
+          "id": [1, 2],
+          "A": [["L", "a", "b"], ["L", "c", "d"]],
+        },
+        "viewApiFetchSelectedRecord": {
+          "id": 2,
+          "A": ["L", "c", "d"]
+        },
+        // onRecords returns rows by default, not columns.
+        "onRecords": [
+          {"id": 1, "A": ["a", "b"]},
+          {"id": 2, "A": ["c", "d"]}
+        ],
+        "onRecord": {
+          "id": 1,
+          "A": ["a", "b"]
+        },
+      },
+      "options": {
+        // This is the result of calling the same methods as above,
+        // but with the values of `keepEncoded` and `format` being the opposite of their defaults.
+        // `includeColumns` is also set to either 'normal' or 'all' instead of the default 'shown',
+        // which means that the 'B' column is included in all the results,
+        // and the 'manualSort' columns is included in half of them.
+        "fetchSelectedTable": [
+          {"id": 1, "manualSort": 1, "A": ["L", "a", "b"], "B": 1},
+          {"id": 2, "manualSort": 2, "A": ["L", "c", "d"], "B": 2},
+        ],
+        "fetchSelectedRecord": {
+          "id": 1,
+          "A": ["L", "a", "b"],
+          "B": 1
+        },
+        "viewApiFetchSelectedTable": [
+          {"id": 1, "manualSort": 1, "A": ["a", "b"], "B": 1},
+          {"id": 2, "manualSort": 2, "A": ["c", "d"], "B": 2}
+        ],
+        "viewApiFetchSelectedRecord": {
+          "id": 2,
+          "A": ["c", "d"],
+          "B": 2
+        },
+        "onRecords": {
+          "id": [1, 2],
+          "manualSort": [1, 2],
+          "A": [["L", "a", "b"], ["L", "c", "d"]],
+          "B": [1, 2],
+        },
+        "onRecord": {
+          "id": 1,
+          "A": ["L", "a", "b"],
+          "B": 1
+        },
+      }
+    };
+
+    async function getData() {
+      await driver.findContentWait('#data', /\{/, 1000);
+      const data = await driver.find('#data').getText();
+      return JSON.parse(data);
+    }
+
+    await inFrame(async () => {
+      const parsed = await getData();
+      assert.deepEqual(parsed, expected);
+    });
+
+    // Change the access level away from 'full'.
+    await setAccess("read table");
+    await gu.waitForServer();
+
+    await inFrame(async () => {
+      const parsed = await getData();
+      // The default options don't require full access, so the result is the same.
+      assert.deepEqual(parsed.default, expected.default);
+
+      // The alternative options all set includeColumns to 'normal' or 'all',
+      // which requires full access.
+      assert.deepEqual(parsed.options, {
+        "onRecord":
+          "Error: Access not granted. Current access level read table",
+        "onRecords":
+          "Error: Access not granted. Current access level read table",
+        "fetchSelectedTable":
+          "Error: Access not granted. Current access level read table",
+        "fetchSelectedRecord":
+          "Error: Access not granted. Current access level read table",
+        "viewApiFetchSelectedTable":
+          "Error: Access not granted. Current access level read table",
+        "viewApiFetchSelectedRecord":
+          "Error: Access not granted. Current access level read table"
+      });
+    });
+  });
 });
 
 async function inFrame(op: () => Promise)  {