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 00000000..d1e1a744 Binary files /dev/null and b/test/fixtures/docs/FetchSelectedOptions.grist differ diff --git a/test/fixtures/sites/fetchSelectedOptions/index.html b/test/fixtures/sites/fetchSelectedOptions/index.html new file mode 100644 index 00000000..21b0e38e --- /dev/null +++ b/test/fixtures/sites/fetchSelectedOptions/index.html @@ -0,0 +1,11 @@ + + + + + + + +

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)  {