diff --git a/app/client/components/CustomView.ts b/app/client/components/CustomView.ts index 76caf650..2de3b150 100644 --- a/app/client/components/CustomView.ts +++ b/app/client/components/CustomView.ts @@ -1,27 +1,25 @@ import * as BaseView from 'app/client/components/BaseView'; import {Cursor} from 'app/client/components/Cursor'; -import { GristDoc } from 'app/client/components/GristDoc'; -import { get as getBrowserGlobals } from 'app/client/lib/browserGlobals'; -import { CustomSectionElement, ViewProcess } from 'app/client/lib/CustomSectionElement'; -import { Disposable } from 'app/client/lib/dispose'; +import * as commands from 'app/client/components/commands'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {ConfigNotifier, CustomSectionAPIImpl, GristDocAPIImpl, GristViewImpl, + MinimumLevel, RecordNotifier, TableNotifier, WidgetAPIImpl, + WidgetFrame} from 'app/client/components/WidgetFrame'; +import {CustomSectionElement, ViewProcess} from 'app/client/lib/CustomSectionElement'; +import {Disposable} from 'app/client/lib/dispose'; import * as dom from 'app/client/lib/dom'; import * as kd from 'app/client/lib/koDom'; import * as DataTableModel from 'app/client/models/DataTableModel'; -import { ViewFieldRec, ViewSectionRec } from 'app/client/models/DocModel'; -import { CustomViewSectionDef } from 'app/client/models/entities/ViewSectionRec'; +import {ViewSectionRec} from 'app/client/models/DocModel'; +import {CustomViewSectionDef} from 'app/client/models/entities/ViewSectionRec'; +import {UserError} from 'app/client/models/errors'; import {SortedRowSet} from 'app/client/models/rowset'; -import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions'; -import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes'; -import { PluginInstance } from 'app/common/PluginInstance'; -import {GristDocAPI, GristView} from 'app/plugin/GristAPI'; +import {PluginInstance} from 'app/common/PluginInstance'; import {Events as BackboneEvents} from 'backbone'; -import {MsgType, Rpc} from 'grain-rpc'; +import {dom as grains} from 'grainjs'; import * as ko from 'knockout'; -import debounce = require('lodash/debounce'); import defaults = require('lodash/defaults'); -import noop = require('lodash/noop'); - -const G = getBrowserGlobals('window'); +import {AccessLevel} from 'app/common/CustomWidget'; /** * CustomView components displays arbitrary html. There are two modes available, in the "url" mode @@ -33,6 +31,21 @@ const G = getBrowserGlobals('window'); */ export class CustomView extends Disposable { + private static _commands = { + async openWidgetConfiguration(this: CustomView) { + if (!this.isDisposed() && !this._frame?.isDisposed()) { + try { + await this._frame.editOptions(); + } catch(err) { + if (err.message === "Unknown interface") { + throw new UserError("Custom widget doesn't expose configuration screen."); + } else { + throw err; + } + } + } + }, + }; /** * The HTMLElement embedding the content. */ @@ -54,9 +67,7 @@ export class CustomView extends Disposable { private _customSection: ViewProcess|undefined; private _pluginInstance: PluginInstance|undefined; - private _updateData: () => void; // debounced call to let the view know linked data changed. - private _updateCursor: () => void; // debounced call to let the view know linked cursor changed. - private _rpc: Rpc; // rpc connection to view. + private _frame: WidgetFrame; // plugin frame (holding external page) private _emptyWidgetPage: string; public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { @@ -78,42 +89,20 @@ export class CustomView extends Disposable { this.autoDispose(this._customDef.pluginId.subscribe(this._updatePluginInstance, this)); this.autoDispose(this._customDef.sectionId.subscribe(this._updateCustomSection, this)); + this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.hasFocus)); this.viewPane = this.autoDispose(this._buildDom()); this._updatePluginInstance(); - - this._updateData = debounce(() => this._updateView(true), 0); - this._updateCursor = debounce(() => this._updateView(false), 0); - - this.autoDispose(this.viewSection.viewFields().subscribe(this._updateData)); - this.listenTo(this.sortedRows, 'rowNotify', this._updateData); - this.autoDispose(this.sortedRows.getKoArray().subscribe(this._updateData)); - - this.autoDispose(this.cursor.rowIndex.subscribe(this._updateCursor)); } public async triggerPrint() { - if (!this.isDisposed() && this._rpc) { - return await this._rpc.callRemoteFunc("print"); - } - } - - private _updateView(dataChange: boolean) { - if (this.isDisposed()) { return; } - if (this._rpc) { - const state = { - tableId: this.viewSection.table().tableId(), - rowId: this.cursor.getCursorPos().rowId || undefined, - dataChange - }; - // tslint:disable-next-line:no-console - this._rpc.postMessage(state).catch(e => console.error('Failed to send view state', e)); - // This post message won't get through if doc access has not been granted to the view. + if (!this.isDisposed() && this._frame) { + return await this._frame.callRemote('print'); } } /** - * Find a plugin instance that matchs the plugin id, update the `found` observables, then tries to + * Find a plugin instance that matches the plugin id, update the `found` observables, then tries to * find a matching section. */ private _updatePluginInstance() { @@ -148,42 +137,6 @@ export class CustomView extends Disposable { } else { this._foundSection(false); } - - } - - /** - * Access data backing the section as a table. This code is borrowed - * with variations from ChartView.ts. - */ - private _getSelectedTable(): BulkColValues { - const fields: ViewFieldRec[] = this.viewSection.viewFields().all(); - const rowIds: number[] = this.sortedRows.getKoArray().peek() as number[]; - const data: BulkColValues = {}; - for (const field of fields) { - // Use the colId of the displayCol, which may be different in case of Reference columns. - const colId: string = field.displayColModel.peek().colId.peek(); - const getter = this.tableModel.tableData.getRowPropFunc(colId)!; - const typeInfo = extractInfoFromColType(field.column.peek().type.peek()); - data[field.column().colId()] = rowIds.map((r) => reencodeAsAny(getter(r)!, typeInfo)); - } - data.id = rowIds; - return data; - } - - private _getSelectedRecord(rowId: number): RowRecord { - // 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 fields: ViewFieldRec[] = this.viewSection.viewFields().all(); - const data: RowRecord = {id: rowId}; - for (const field of fields) { - const colId: string = field.displayColModel.peek().colId.peek(); - const typeInfo = extractInfoFromColType(field.column.peek().type.peek()); - data[field.column().colId()] = reencodeAsAny(this.tableModel.tableData.getValue(rowId, colId)!, typeInfo); - } - return data; } private _buildDom() { @@ -204,7 +157,7 @@ export class CustomView extends Disposable { dom.autoDispose(showPluginContent), // todo: should display content in webview when running electron kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) => - _mode === "url" ? this._buildIFrame(_url, _access) : null), + _mode === "url" ? this._buildIFrame(_url, (_access || AccessLevel.none) as AccessLevel) : null), kd.maybe(showPluginNotification, () => buildNotification('Plugin ', dom('strong', kd.text(this._customDef.pluginId)), ' was not found', dom.testId('customView_notification_plugin') @@ -220,119 +173,55 @@ export class CustomView extends Disposable { ); } - private _buildIFrame(baseUrl: string, access: string) { - // This is a url-flavored custom view. - // Here we create an iframe, and add hooks for sending - // messages to it and receiving messages from it. - - // Compute a url for the view. We add in a parameter called "access" - // so the page can determine what access level has been granted to it - // in a simple and unambiguous way. - let fullUrl: string; - if (!baseUrl) { - fullUrl = this._emptyWidgetPage; - } else { - const url = new URL(baseUrl); - url.searchParams.append('access', access); - fullUrl = url.href; + private _promptAccess(access: AccessLevel) { + if (this.gristDoc.isReadonly.get()) { + return; } + this.viewSection.desiredAccessLevel(access); + } - if (!access) { access = 'none'; } - const someAccess = (access !== 'none'); - const fullAccess = (access === 'full'); - - // Create an Rpc object to manage messaging. - const rpc = new Rpc({}); - // Now, we create a listener for message events (if access was granted), making sure - // to respond only to messages from our iframe. - const listener = someAccess ? (event: MessageEvent) => { - if (event.source === iframe.contentWindow) { - // Previously, we forwarded messages targeted at "grist" to the back-end. - // Now, we process them immediately in the context of the client for access - // control purposes. To do that, any message that comes in with mdest of - // "grist" will have that destination wiped, and we provide a local - // implementation of the interface. - // It feels like it should be possible to deal with the mdest more cleanly, - // with a rpc.registerForwarder('grist', { ... }), but it seems somehow hard - // to call a locally registered interface of an rpc object? - if (event.data.mdest === 'grist') { - event.data.mdest = ''; - } - rpc.receiveMessage(event.data); - if (event.data.mtype === MsgType.Ready) { - // After, the "ready" message, send a notification with cursor - // (if available). - this._updateView(true); - } - } - } : null; - // Add the listener only if some access has been granted. - if (listener) { G.window.addEventListener('message', listener); } - // Here is the actual iframe. - const iframe = dom('iframe.custom_view.clipboard_focus', - {src: fullUrl}, - dom.onDispose(() => { - if (listener) { G.window.removeEventListener('message', listener); } - })); - if (someAccess) { - // When replies come back, forward them to the iframe if access - // is granted. - rpc.setSendMessage(msg => { - iframe.contentWindow!.postMessage(msg, '*'); - }); - // Register a way for the view to access the data backing the view. - rpc.registerImpl('GristView', { - fetchSelectedTable: () => this._getSelectedTable(), - fetchSelectedRecord: (rowId: number) => this._getSelectedRecord(rowId), - }); - // Add a GristDocAPI implementation. Apart from getDocName (which I think - // is from a time before names and ids diverged, so I'm not actually sure - // what it should return), require full access since the methods can view/edit - // parts of the document beyond the table the widget is associated with. - // Access rights will be that of the user viewing the document. - // TODO: add something to calls to identify the origin, so it could be - // controlled by access rules if desired. - const assertFullAccess = () => { - if (!fullAccess) { throw new Error('full access not granted'); } - }; - rpc.registerImpl('GristDocAPI', { - getDocName: () => this.gristDoc.docId, - listTables: async () => { - assertFullAccess(); - // Could perhaps read tableIds from this.gristDoc.docModel.allTableIds.all()? - const tables = await this.gristDoc.docComm.fetchTable('_grist_Tables'); - // Tables the user doesn't have access to are just blanked out. - return tables[3].tableId.filter(tableId => tableId !== ''); - }, - fetchTable: async (tableId: string) => { - assertFullAccess(); - return fromTableDataAction(await this.gristDoc.docComm.fetchTable(tableId)); - }, - applyUserActions: (actions: any[][]) => { - assertFullAccess(); - return this.gristDoc.docComm.applyUserActions(actions, {desc: undefined}); + private _buildIFrame(baseUrl: string, access: AccessLevel) { + return grains.create(WidgetFrame, { + url: baseUrl || this._emptyWidgetPage, + access, + readonly: this.gristDoc.isReadonly.get(), + configure: (frame) => { + this._frame = frame; + // Need to cast myself to a BaseView + const view = this as unknown as BaseView; + frame.exposeAPI( + "GristDocAPI", + new GristDocAPIImpl(this.gristDoc), + GristDocAPIImpl.defaultAccess); + frame.exposeAPI( + "GristView", + new GristViewImpl(view), new MinimumLevel(AccessLevel.read_table)); + frame.exposeAPI( + "CustomSectionAPI", + new CustomSectionAPIImpl( + this.viewSection, + access, + this._promptAccess.bind(this)), + new MinimumLevel(AccessLevel.none)); + frame.useEvents(RecordNotifier.create(frame, view), new MinimumLevel(AccessLevel.read_table)); + frame.useEvents(TableNotifier.create(frame, view), new MinimumLevel(AccessLevel.read_table)); + frame.exposeAPI( + "WidgetAPI", + new WidgetAPIImpl(this.viewSection), + new MinimumLevel(AccessLevel.none)); // none access is enough + frame.useEvents( + ConfigNotifier.create(frame, this.viewSection, access), + new MinimumLevel(AccessLevel.none)); // none access is enough + }, + onElem: (iframe) => onFrameFocus(iframe, () => { + if (this.isDisposed()) { return; } + if (!this.viewSection.isDisposed() && !this.viewSection.hasFocus()) { + this.viewSection.hasFocus(true); } - }); - } else { - // Direct messages to /dev/null otherwise. Important to setSendMessage - // or they will be queued indefinitely. - rpc.setSendMessage(noop); - } - // We send events via the rpc object when the data backing the view changes - // or the cursor changes. - if (this._rpc) { - // There's an existing RPC object we are replacing. - // Unregister anything that may have been registered previously. - // TODO: add a way to clean up more systematically to grain-rpc. - this._rpc.unregisterForwarder('grist'); - this._rpc.unregisterImpl('GristDocAPI'); - this._rpc.unregisterImpl('GristView'); - } - this._rpc = rpc; - return iframe; - } + }) + }); - private listenTo(...args: any[]): void { /* replaced by Backbone */ } + } } // Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts @@ -344,3 +233,52 @@ Object.assign(CustomView.prototype, BackboneEvents); function buildNotification(...args: any[]) { return dom('div.custom_view_notification.bg-warning', dom('p', ...args)); } + +/** + * There is no way to detect if the frame was clicked. This causes a bug, when + * there are 2 custom widgets on a page then user can't switch focus from 1 section + * to another. The only solution is too pool and test if the iframe is an active element + * in the dom. + * (See https://stackoverflow.com/questions/2381336/detect-click-into-iframe-using-javascript). + * + * For a single iframe, it will gain focus through a hack in ViewLayout.ts. + */ +function onFrameFocus(frame: HTMLIFrameElement, handler: () => void) { + let timer: NodeJS.Timeout|null = null; + // Flag that will prevent mouseenter event to be fired + // after dom is disposed. This shouldn't happen. + let disposed = false; + // Stops pooling. + function stop() { + if (timer) { + clearInterval(timer); + timer = null; + } + } + return grains.update(frame, + grains.on("mouseenter", () => { + // Make sure we weren't dispose (should not happen) + if (disposed) { return; } + // If frame already has focus, do nothing. + // NOTE: Frame will always be an active element from our perspective, + // even if the focus is somewhere inside the iframe. + if (document.activeElement === frame) { return; } + // Start pooling for frame focus. + timer = setInterval(() => { + if (document.activeElement === frame) { + try { + handler(); + } finally { + // Stop checking, we will start again after next mouseenter. + stop(); + } + } + }, 70); // 70 is enough to make it look like a click. + }), + grains.on("mouseleave", stop), + grains.onDispose(() => { + stop(); + disposed = true; + }) + ); +} diff --git a/app/client/components/WidgetFrame.ts b/app/client/components/WidgetFrame.ts new file mode 100644 index 00000000..c240a884 --- /dev/null +++ b/app/client/components/WidgetFrame.ts @@ -0,0 +1,554 @@ +import * as BaseView from 'app/client/components/BaseView'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; +import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; +import {AccessLevel, isSatisfied} from 'app/common/CustomWidget'; +import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; +import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions'; +import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes'; +import {CustomSectionAPI, GristDocAPI, GristView, InteractionOptionsRequest, + WidgetAPI} from 'app/plugin/grist-plugin-api'; +import {MsgType, Rpc} from 'grain-rpc'; +import {Computed, dom} from 'grainjs'; +import noop = require('lodash/noop'); +import debounce = require('lodash/debounce'); +import isEqual = require('lodash/isEqual'); + +/** + * This file contains a WidgetFrame and all its components. + * + * WidgetFrame embeds an external Custom Widget (external webpage) in an iframe. It is used on a CustomView, + * to display widget content, and on the configuration screen to display widget's configuration screen. + * + * Beside exposing widget content, it also exposes some of the API's that Grist offers via grist-rpc. + * API are defined in the core/app/plugin/grist-plugin-api.ts. + */ + +const G = getBrowserGlobals('window'); + +/** + * Options for WidgetFrame + */ +export interface WidgetFrameOptions { + /** + * Url of external page. Iframe is rebuild each time the URL changes. + */ + url: string; + /** + * Assigned access level. Iframe is rebuild each time access level is changed. + */ + access: AccessLevel; + /** + * If document is in readonly mode. + */ + readonly: boolean; + /** + * Optional callback to configure exposed API. + */ + configure?: (frame: WidgetFrame) => void; + /** + * Optional handler to modify the iframe. + */ + onElem?: (iframe: HTMLIFrameElement) => void; +} + +/** + * Iframe that embeds Custom Widget page and exposes Grist API. + */ +export class WidgetFrame extends DisposableWithEvents { + // A grist-rpc object, encapsulated to prevent direct access. + private _rpc: Rpc; + // Created iframe element, used to receive and post messages via Rpc + private _iframe: HTMLIFrameElement | null; + + constructor(private _options: WidgetFrameOptions) { + super(); + _options.access = _options.access || AccessLevel.none; + // Build RPC object and connect it to iframe. + this._rpc = new Rpc({}); + + // Register outgoing message handler. + this._rpc.setSendMessage(msg => this._iframe?.contentWindow!.postMessage(msg, '*')); + + // Register incoming message handler. + const listener = this._onMessage.bind(this); + // 'message' is an event's name used by Rpc in window to iframe communication. + G.window.addEventListener('message', listener); + this.onDispose(() => { + // Stop listening for events from the iframe. + G.window.removeEventListener('message', listener); + // Stop sending messages to the iframe. + this._rpc.setSendMessage(noop); + }); + + // Call custom configuration handler. + _options.configure?.(this); + } + + /** + * Attach an EventSource with desired access level. + */ + public useEvents(source: IEventSource, access: AccessChecker) { + // Wrap event handler with access check. + const handler = async (data: any) => { + if (access.check(this._options.access)) { + await this._rpc.postMessage(data); + } + }; + this.listenTo(source, 'event', handler); + // Give EventSource a chance to attach to WidgetFrame events. + source.attach(this); + } + + /** + * Exposes API for Custom Widget. + * TODO: add ts-interface support. Currently all APIs are written in typescript, + * so those checks are not that needed. + */ + public exposeAPI(name: string, api: any, access: AccessChecker) { + this._rpc.registerImpl(name, wrapObject(api, access, this._options.access)); + this.onDispose(() => this._rpc.unregisterImpl(name)); + } + + /** + * Expose a method for Custom Widget. + */ + public exposeMethod(name: string, handler: (...args: any[]) => any, access: AccessChecker) { + this._rpc.registerFunc(name, (...args: any[]) => { + if (access.check(this._options.access, 'invoke')) { + return handler(...args); + } else { + throwError(this._options.access); + } + }); + } + + /** + * Make configure call to the widget. Widget should open some configuration screen or ignore it. + */ + public editOptions() { + return this.callRemote('editOptions'); + } + + /** + * Call remote function that is exposed by the widget. + */ + public callRemote(name: string, ...args: any[]) { + return this._rpc.callRemoteFunc(name, ...args); + } + + public buildDom() { + // Append access level to query string. + const urlWithAccess = (url: string) => { + if (!url) { + return url; + } + const urlObj = new URL(url); + urlObj.searchParams.append('access', this._options.access); + urlObj.searchParams.append('readonly', String(this._options.readonly)); + return urlObj.href; + }; + const fullUrl = urlWithAccess(this._options.url); + const onElem = this._options.onElem ?? ((el: HTMLIFrameElement) => el); + return onElem( + (this._iframe = dom('iframe', dom.cls('clipboard_focus'), dom.cls('custom_view'), { + src: fullUrl, + })) + ); + } + + private _onMessage(event: MessageEvent) { + if (this._iframe && event.source === this._iframe.contentWindow && !this.isDisposed()) { + // Previously, we forwarded messages targeted at "grist" to the back-end. + // Now, we process them immediately in the context of the client for access + // control purposes. To do that, any message that comes in with mdest of + // "grist" will have that destination wiped, and we provide a local + // implementation of the interface. + // It feels like it should be possible to deal with the mdest more cleanly, + // with a rpc.registerForwarder('grist', { ... }), but it seems somehow hard + // to call a locally registered interface of an rpc object? + if (event.data.mdest === 'grist') { + event.data.mdest = ''; + } + if (event.data.mtype === MsgType.Ready) { + this.trigger('ready', this); + } + this._rpc.receiveMessage(event.data); + } + } +} + +const throwError = (access: AccessLevel) => { + throw new Error('Access not granted. Current access level ' + access); +}; + +/** + * Wraps an object to check access level before it is called. + * TODO: grain-rpc exposes callWrapper which could be used for this purpose, + * but currently it doesn't have access to the incoming message. + */ +function wrapObject(impl: T, accessChecker: AccessChecker, access: AccessLevel): T { + return new Proxy(impl, { + // This proxies all the calls to methods on the API. + get(target: any, methodName: string) { + return function () { + if (methodName === 'then') { + // Making a proxy for then invocation is not a good idea. + return undefined; + } + if (accessChecker.check(access, methodName)) { + return target[methodName](...arguments); + } else { + throwError(access); + } + }; + }, + }); +} + +/** + * Interface for custom access rules. + */ +export interface AccessChecker { + /** + * Checks if the incoming call can be served on current access level. + * @param access Current access level + * @param method Method called on the interface, can use * or undefined to match all methods. + */ + check(access: AccessLevel, method?: string): boolean; +} + +/** + * Checks if current access level is enough. + */ +export class MinimumLevel implements AccessChecker { + constructor(private _minimum: AccessLevel) {} + public check(access: AccessLevel): boolean { + return isSatisfied(access, this._minimum); + } +} + +type MethodMatcher = keyof T | '*'; +/** + * Helper object that allows assigning access level to a particular method in the interface. + * + * Example: + * + * 1. Expose two methods, all other will be denied (even in full access mode) + * new ApiGranularAccess() + * .require("read_table", "method1") // for method1 we need at least read_table + * .require("none", "method2") // for method2 no access level is needed + * + * 2. Expose two methods, all other will require full access (effectively the same as ex. 1) + * new ApiGranularAccess() + * .require("read_table", "method1") // for method1 we need at least read_table + * .require("none", "method2") // for method2 no access level is needed + * .require("full", "*") // for any other, require full + * + * 3. Expose all methods on read_table access, but one can have none + * new ApiGranularAccess() + * .require("none", "method2") // for method2 we are ok with none access + * .require("read_table", "*") // for any other, require read_table + */ +export class MethodAccess implements AccessChecker { + private _accessMap: Map, AccessLevel> = new Map(); + constructor() {} + public require(level: AccessLevel, method: MethodMatcher = '*') { + this._accessMap.set(method, level); + return this; + } + public check(access: AccessLevel, method?: string): boolean { + if (!method) { + throw new Error('Method name is required for MethodAccess check'); + } + // Check if the iface was registered. + if (this._accessMap.has(method as MethodMatcher)) { + // If it was, check that minimum access level is granted. + const minimum = this._accessMap.get(method as MethodMatcher)!; + return isSatisfied(access, minimum); + } else if (this._accessMap.has('*')) { + // If there is a default rule, check if it permits the access. + const minimum = this._accessMap.get('*')!; + return isSatisfied(access, minimum); + } else { + // By default, don't allow anything on this interface. + return false; + } + } +} + +/*********************** + * Exposed APIs for Custom Widgets. + * + * Currently we expose 3 APIs + * - GristDocAPI - full access to document. + * - ViewAPI - access to current table. + * - WidgetAPI - access to widget configuration. + ***********************/ + +/** + * GristDocApi implemented over active GristDoc. + */ +export class GristDocAPIImpl implements GristDocAPI { + public static readonly defaultAccess = new MethodAccess() + .require(AccessLevel.read_table, 'getDocName') + .require(AccessLevel.full); // for any other, require full Access. + + constructor(private _doc: GristDoc) {} + + public async getDocName() { + return this._doc.docId(); + } + + public async listTables(): Promise { + // Could perhaps read tableIds from this.gristDoc.docModel.allTableIds.all()? + const tables = await this._doc.docComm.fetchTable('_grist_Tables'); + // Tables the user doesn't have access to are just blanked out. + return tables[3].tableId.filter(tableId => tableId !== '') as string[]; + } + + public async fetchTable(tableId: string) { + return fromTableDataAction(await this._doc.docComm.fetchTable(tableId)); + } + + public async applyUserActions(actions: any[][]) { + return this._doc.docComm.applyUserActions(actions, {desc: undefined}); + } +} + +/** + * GristViewAPI implemented over BaseView. + */ +export class GristViewImpl implements GristView { + constructor(private _baseView: BaseView) {} + + public async fetchSelectedTable(): Promise { + const fields: ViewFieldRec[] = this._baseView.viewSection.viewFields().all(); + const rowIds: number[] = this._baseView.sortedRows.getKoArray().peek() as number[]; + const data: BulkColValues = {}; + for (const field of fields) { + // Use the colId of the displayCol, which may be different in case of Reference columns. + const colId: string = field.displayColModel.peek().colId.peek(); + const getter = this._baseView.tableModel.tableData.getRowPropFunc(colId)!; + const typeInfo = extractInfoFromColType(field.column.peek().type.peek()); + data[field.column().colId()] = rowIds.map(r => reencodeAsAny(getter(r)!, typeInfo)); + } + data.id = rowIds; + return data; + } + + public async fetchSelectedRecord(rowId: number): 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 fields: ViewFieldRec[] = this._baseView.viewSection.viewFields().all(); + const data: RowRecord = {id: rowId}; + for (const field of fields) { + const colId: string = field.displayColModel.peek().colId.peek(); + const typeInfo = extractInfoFromColType(field.column.peek().type.peek()); + data[field.column().colId()] = reencodeAsAny( + this._baseView.tableModel.tableData.getValue(rowId, colId)!, + typeInfo + ); + } + return data; + } +} + +/** + * WidgetAPI implemented over active section. + */ +export class WidgetAPIImpl implements WidgetAPI { + constructor(private _section: ViewSectionRec) {} + + /** + * Stores options in viewSection.customDef.widgetDef json field. + * This way whenever widget is changed, options are removed and not shared + * between widgets by design. + */ + public async setOptions(options: object): Promise { + console.debug(`set options`, options); + if (options === null || options === undefined || typeof options !== 'object') { + throw new Error('options must be a valid JSON object'); + } + this._section.activeCustomOptions(options); + } + + public async getOptions(): Promise | null> { + console.debug(`getOptions`); + return this._section.activeCustomOptions.peek() ?? null; + } + + public async clearOptions(): Promise { + console.debug(`clearOptions`); + this._section.activeCustomOptions(null); + } + + public async setOption(key: string, value: any): Promise { + console.debug(`setOption(${key}, ${value})`); + const options = {...this._section.activeCustomOptions.peek()}; + options[key] = value; + this._section.activeCustomOptions(options); + } + + public getOption(key: string): Promise { + console.debug(`getOption(${key})`); + const options = this._section.activeCustomOptions.peek(); + return options?.[key]; + } +} + +/************************ + * Events that are sent to the CustomWidget. + * + * Currently: + * - onRecord, implemented by RecordNotifier, sends a message each time active row is changed. + * - onRecords, implemented by TableNotifier, sends a message each time table is changed + * - onOptions, implemented by ConfigNotifier, sends a message each time configuration is changed + * + * All of those events are also sent when CustomWidget sends its ready message. + ************************/ + +/** + * EventSource should trigger event called "event" that will be send to the Custom Widget. + */ +export interface IEventSource extends DisposableWithEvents { + /** + * Called by WidgetFrame, allowing EventSource to attach to its ready event. + */ + attach(frame: WidgetFrame): void; +} + +export class BaseEventSource extends DisposableWithEvents implements IEventSource { + // Attaches to WidgetFrame ready event. + public attach(frame: WidgetFrame): void { + this.listenTo(frame, 'ready', this._ready.bind(this)); + } + protected _ready() { + // To override if needed to react on the ready event. + } + protected _notify(data: any) { + if (this.isDisposed()) { + return; + } + this.trigger('event', data); + } +} + +/** + * Notifies about cursor position change. Exposed in the API as a onRecord handler. + */ +export class RecordNotifier extends BaseEventSource { + private _debounced: () => void; // debounced call to let the view know linked cursor changed. + constructor(private _baseView: BaseView) { + super(); + this._debounced = debounce(() => this._update(), 0); + this.autoDispose(_baseView.cursor.rowIndex.subscribe(this._debounced)); + } + + private _update() { + if (this.isDisposed()) { + return; + } + const state = { + tableId: this._baseView.viewSection.table().tableId(), + rowId: this._baseView.cursor.getCursorPos().rowId || undefined, + dataChange: false, + }; + this._notify(state); + } +} + +/** + * Notifies about options position change. Exposed in the API as a onOptions handler. + */ +export class ConfigNotifier extends BaseEventSource { + private _currentConfig: Computed; + private _debounced: () => void; // debounced call to let the view know linked cursor changed. + constructor(private _section: ViewSectionRec, private _accessLevel: AccessLevel) { + super(); + this._currentConfig = Computed.create(this, use => { + const options = use(this._section.activeCustomOptions); + return options; + }); + this._debounced = debounce(() => this._update(), 0); + this.autoDispose( + this._currentConfig.addListener((cur, prev) => { + if (isEqual(prev, cur)) { + return; + } + this._debounced(); + }) + ); + } + + protected _ready() { + // On ready, send initial configuration. + this._debounced(); + } + + private _update() { + if (this.isDisposed()) { + return; + } + this._notify({ + options: this._currentConfig.get(), + settings: {accessLevel: this._accessLevel}, + }); + } +} + +/** + * Notifies about cursor table data or structure change. + * Exposed in the API as a onRecords handler. + * This Notifier sends an initial event when subscribed + */ +export class TableNotifier extends BaseEventSource { + private _debounced: () => void; // debounced call to let the view know linked data changed. + constructor(private _baseView: BaseView) { + super(); + this._debounced = debounce(() => this._update(), 0); + this.autoDispose(_baseView.viewSection.viewFields().subscribe(this._debounced)); + this.listenTo(_baseView.sortedRows, 'rowNotify', this._debounced); + this.autoDispose(_baseView.sortedRows.getKoArray().subscribe(this._debounced)); + } + + protected _ready() { + // On ready, send initial table information. + this._debounced(); + } + + private _update() { + if (this.isDisposed()) { + return; + } + const state = { + tableId: this._baseView.viewSection.table().tableId(), + rowId: this._baseView.cursor.getCursorPos().rowId || undefined, + dataChange: true, + }; + this._notify(state); + } +} + +export class CustomSectionAPIImpl implements CustomSectionAPI { + constructor( + private _section: ViewSectionRec, + private _currentAccess: AccessLevel, + private _promptCallback: (access: AccessLevel) => void + ) {} + /** + * Method called as part of ready message. Allows widget to request for particular features or inform about + * capabilities. + */ + public async configure(settings: InteractionOptionsRequest): Promise { + if (settings.hasCustomOptions !== undefined) { + this._section.hasCustomOptions(settings.hasCustomOptions); + } + if (settings.requiredAccess && settings.requiredAccess !== this._currentAccess) { + this._promptCallback(settings.requiredAccess as AccessLevel); + } + } +} diff --git a/app/client/components/commandList.js b/app/client/components/commandList.js index 45884de6..a1483608 100644 --- a/app/client/components/commandList.js +++ b/app/client/components/commandList.js @@ -97,6 +97,11 @@ exports.groups = [{ name: 'printSection', keys: [], desc: 'Print currently selected page widget', + }, + { + name: 'openWidgetConfiguration', + keys: [], + desc: 'Open Custom widget configuration screen', } ] }, { diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index 228a349d..40040f3b 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -61,7 +61,7 @@ declare module "app/client/components/BaseView" { public activeFieldBuilder: ko.Computed; public disableEditing: ko.Computed; public isTruncated: ko.Observable; - protected tableModel: DataTableModel; + public tableModel: DataTableModel; constructor(gristDoc: GristDoc, viewSectionModel: any); public setCursorPos(cursorPos: CursorPos): void; diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 9bcc7d56..64c873e9 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -1,7 +1,7 @@ import * as BaseView from 'app/client/components/BaseView'; import { ColumnRec, FilterRec, TableRec, ViewFieldRec, ViewRec } from 'app/client/models/DocModel'; import * as modelUtil from 'app/client/models/modelUtil'; -import {ICustomWidget} from 'app/common/CustomWidget'; +import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget'; import * as ko from 'knockout'; import { CursorPos, } from 'app/client/components/Cursor'; import { KoArray, } from 'app/client/lib/koArray'; @@ -123,6 +123,11 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> { // Number for frozen columns to display. // We won't freeze all the columns on a grid, it will leave at least 1 column unfrozen. numFrozen: ko.Computed; + activeCustomOptions: modelUtil.CustomComputed; + // Temporary variable holding flag that describes if the widget supports custom options (set by api). + hasCustomOptions: ko.Observable; + // Temporary variable holding widget desired access (changed either from manifest or via api). + desiredAccessLevel: ko.Observable; // Save all filters of fields/columns in the section. saveFilters(): Promise; @@ -150,6 +155,10 @@ export interface CustomViewSectionDef { * Custom widget information. */ widgetDef: modelUtil.KoSaveableObservable; + /** + * Custom widget options. + */ + widgetOptions: modelUtil.KoSaveableObservable|null>; /** * Access granted to url. */ @@ -206,13 +215,17 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): mode: customDefObj.prop('mode'), url: customDefObj.prop('url'), widgetDef: customDefObj.prop('widgetDef'), + widgetOptions: customDefObj.prop('widgetOptions'), access: customDefObj.prop('access'), pluginId: customDefObj.prop('pluginId'), sectionId: customDefObj.prop('sectionId') }; - this.saveCustomDef = () => { - return customDefObj.save(); + this.activeCustomOptions = modelUtil.customValue(this.customDef.widgetOptions); + + this.saveCustomDef = async () => { + await customDefObj.save(); + this.activeCustomOptions.revert(); }; this.themeDef = modelUtil.fieldWithDefault(this.theme, 'form'); @@ -447,4 +460,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): ) ) ); + + this.hasCustomOptions = ko.observable(false); + this.desiredAccessLevel = ko.observable(null); } diff --git a/app/client/ui/CustomSectionConfig.ts b/app/client/ui/CustomSectionConfig.ts index 69a156b8..af9c504d 100644 --- a/app/client/ui/CustomSectionConfig.ts +++ b/app/client/ui/CustomSectionConfig.ts @@ -1,24 +1,23 @@ +import {allCommands} from 'app/client/components/commands'; +import {GristDoc} from 'app/client/components/GristDoc'; import * as kf from 'app/client/lib/koForm'; import {ViewSectionRec} from 'app/client/models/DocModel'; import {reportError} from 'app/client/models/errors'; import {cssLabel, cssRow, cssTextInput} from 'app/client/ui/RightPanel'; -import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; +import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons'; import {colors} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {IOptionFull, select} from 'app/client/ui2018/menus'; -import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget'; +import {AccessLevel, ICustomWidget, isSatisfied} from 'app/common/CustomWidget'; import {GristLoadConfig} from 'app/common/gristUrls'; import {nativeCompare} from 'app/common/gutil'; -import {UserAPI} from 'app/common/UserAPI'; -import {bundleChanges, Computed, Disposable, dom, - makeTestId, MultiHolder, Observable, styled} from 'grainjs'; -import {icon} from 'app/client/ui2018/icons'; +import {bundleChanges, Computed, Disposable, dom, fromKo, makeTestId, MultiHolder, Observable, styled} from 'grainjs'; // Custom URL widget id - used as mock id for selectbox. -const CUSTOM_ID = "custom"; +const CUSTOM_ID = 'custom'; const testId = makeTestId('test-config-widget-'); - /** * Custom Widget section. * Allows to select custom widget from the list of available widgets @@ -34,23 +33,25 @@ const testId = makeTestId('test-config-widget-'); export class CustomSectionConfig extends Disposable { // Holds all available widget definitions. private _widgets: Observable; - // Holds selected option (either custom or a widgetId). - private _selected: Computed; + // Holds selected option (either custom string or a widgetId). + private _selectedId: Computed; // Holds custom widget URL. private _url: Computed; // Enable or disable widget repository. private _canSelect = true; - // Selected access level. - private _selectedAccess: Computed; // When widget is changed, it sets its desired access level. We will prompt // user to approve or reject it. - private _desiredAccess: Observable; + private _desiredAccess: Observable; // Current access level (stored inside a section). private _currentAccess: Computed; + // Does widget has custom configuration. + private _hasConfiguration: Computed; - constructor(section: ViewSectionRec, api: UserAPI) { + constructor(_section: ViewSectionRec, _gristDoc: GristDoc) { super(); + const api = _gristDoc.app.topAppModel.api; + // Test if we can offer widget list. const gristConfig: GristLoadConfig = (window as any).gristConfig || {}; this._canSelect = gristConfig.enableWidgetRepository ?? true; @@ -61,107 +62,116 @@ export class CustomSectionConfig extends Disposable { if (this._canSelect) { // From the start we will provide single widget definition // that was chosen previously. - if (section.customDef.widgetDef.peek()) { - this._widgets.set([section.customDef.widgetDef.peek()!]); + if (_section.customDef.widgetDef.peek()) { + this._widgets.set([_section.customDef.widgetDef.peek()!]); } // Request for rest of the widgets. - api.getWidgets().then(widgets => { - if (this.isDisposed()) { - return; - } - const existing = section.customDef.widgetDef.peek(); - // Make sure we have current widget in place. - if (existing && !widgets.some(w => w.widgetId === existing.widgetId)) { - widgets.push(existing); - } - this._widgets.set(widgets.sort((a, b) => nativeCompare(a.name.toLowerCase(), b.name.toLowerCase()))); - }).catch(err => { - reportError(err); - }); + api + .getWidgets() + .then(widgets => { + if (this.isDisposed()) { + return; + } + const existing = _section.customDef.widgetDef.peek(); + // Make sure we have current widget in place. + if (existing && !widgets.some(w => w.widgetId === existing.widgetId)) { + widgets.push(existing); + } + this._widgets.set(widgets.sort((a, b) => nativeCompare(a.name.toLowerCase(), b.name.toLowerCase()))); + }) + .catch(reportError); } // Create temporary variable that will hold blank Custom Url state. When url is blank and widgetDef is not stored // we can either show "Select Custom Widget" or a Custom Url with a blank url. // To distinguish those states, we will mark Custom Url state at start (by checking that url is not blank and // widgetDef is not set). And then switch it during selectbox manipulation. - const wantsToBeCustom = Observable.create(this, - Boolean(section.customDef.url.peek() && !section.customDef.widgetDef.peek()) + const wantsToBeCustom = Observable.create( + this, + Boolean(_section.customDef.url.peek() && !_section.customDef.widgetDef.peek()) ); // Selected value from the dropdown (contains widgetId or "custom" string for Custom URL) - this._selected = Computed.create(this, use => { - if (use(section.customDef.widgetDef)) { - return section.customDef.widgetDef.peek()!.widgetId; + this._selectedId = Computed.create(this, use => { + if (use(_section.customDef.widgetDef)) { + return _section.customDef.widgetDef.peek()!.widgetId; } - if (use(section.customDef.url) || use(wantsToBeCustom)) { + if (use(_section.customDef.url) || use(wantsToBeCustom)) { return CUSTOM_ID; } return null; }); - this._selected.onWrite(async (value) => { + this._selectedId.onWrite(async value => { if (value === CUSTOM_ID) { // Select Custom URL bundleChanges(() => { // Clear url. - section.customDef.url(null); + _section.customDef.url(null); // Clear widget definition. - section.customDef.widgetDef(null); + _section.customDef.widgetDef(null); // Set intermediate state wantsToBeCustom.set(true); // Reset access level to none. - section.customDef.access(AccessLevel.none); + _section.customDef.access(AccessLevel.none); + // Clear all saved options. + _section.customDef.widgetOptions(null); + // Reset custom configuration flag. + _section.hasCustomOptions(false); this._desiredAccess.set(AccessLevel.none); }); - await section.saveCustomDef(); + await _section.saveCustomDef(); } else { // Select Widget const selectedWidget = this._widgets.get().find(w => w.widgetId === value); if (!selectedWidget) { // should not happen - throw new Error("Error accessing widget from the list"); + throw new Error('Error accessing widget from the list'); } // If user selected the same one, do nothing. - if (section.customDef.widgetDef.peek()?.widgetId === value) { + if (_section.customDef.widgetDef.peek()?.widgetId === value) { return; } bundleChanges(() => { // Clear access level - section.customDef.access(AccessLevel.none); + _section.customDef.access(AccessLevel.none); // When widget wants some access, set desired access level. this._desiredAccess.set(selectedWidget.accessLevel || AccessLevel.none); // Update widget definition. - section.customDef.widgetDef(selectedWidget); + _section.customDef.widgetDef(selectedWidget); // Update widget URL. - section.customDef.url(selectedWidget.url); + _section.customDef.url(selectedWidget.url); + // Clear options. + _section.customDef.widgetOptions(null); + // Clear has custom configuration. + _section.hasCustomOptions(false); // Clear intermediate state. wantsToBeCustom.set(false); }); - await section.saveCustomDef(); + await _section.saveCustomDef(); } }); // Url for the widget, taken either from widget definition, or provided by hand for Custom URL. // For custom widget, we will store url also in section definition. - this._url = Computed.create(this, use => use(section.customDef.url) || ""); - this._url.onWrite((newUrl) => section.customDef.url.setAndSave(newUrl)); + this._url = Computed.create(this, use => use(_section.customDef.url) || ''); + this._url.onWrite(newUrl => _section.customDef.url.setAndSave(newUrl)); // Compute current access level. - this._currentAccess = Computed.create(this, - use => use(section.customDef.access) as AccessLevel || AccessLevel.none); - - // From the start desired access level is the same as current one. - this._desiredAccess = Observable.create(this, this._currentAccess.get()); - - // Selected access level will show desired one, but will updated both (desired and current). - this._selectedAccess = Computed.create(this, use => use(this._desiredAccess)); - this._selectedAccess.onWrite(async newAccess => { - this._desiredAccess.set(newAccess); - await section.customDef.access.setAndSave(newAccess); + this._currentAccess = Computed.create( + this, + use => (use(_section.customDef.access) as AccessLevel) || AccessLevel.none + ); + this._currentAccess.onWrite(async newAccess => { + await _section.customDef.access.setAndSave(newAccess); }); + // From the start desired access level is the same as current one. + this._desiredAccess = fromKo(_section.desiredAccessLevel); // Clear intermediate state when section changes. - this.autoDispose(section.id.subscribe(() => wantsToBeCustom.set(false))); - this.autoDispose(section.id.subscribe(() => this._reject())); + this.autoDispose(_section.id.subscribe(() => wantsToBeCustom.set(false))); + this.autoDispose(_section.id.subscribe(() => this._reject())); + + this._hasConfiguration = Computed.create(this, use => use(_section.hasCustomOptions)); } public buildDom() { @@ -169,16 +179,29 @@ export class CustomSectionConfig extends Disposable { const holder = new MultiHolder(); // Show prompt, when desired access level is different from actual one. - const prompt = Computed.create(holder, use => use(this._currentAccess) !== use(this._desiredAccess)); + const prompt = Computed.create(holder, use => + use(this._desiredAccess) + && !isSatisfied(use(this._currentAccess), use(this._desiredAccess)!)); // If this is empty section or not. - const isSelected = Computed.create(holder, use => Boolean(use(this._selected))); + const isSelected = Computed.create(holder, use => Boolean(use(this._selectedId))); // If user is using custom url. - const isCustom = Computed.create(holder, use => use(this._selected) === CUSTOM_ID || !this._canSelect); - // Options for the selectbox (all widgets definitions and Custom URL) + const isCustom = Computed.create(holder, use => use(this._selectedId) === CUSTOM_ID || !this._canSelect); + // Options for the select-box (all widgets definitions and Custom URL) const options = Computed.create(holder, use => [ {label: 'Custom URL', value: 'custom'}, ...use(this._widgets).map(w => ({label: w.name, value: w.widgetId})), ]); + function buildPrompt(level: AccessLevel|null) { + if (!level) { + return null; + } + switch(level) { + case AccessLevel.none: return cssConfirmLine("Widget does not require any permissions."); + case AccessLevel.read_table: return cssConfirmLine("Widget needs to ", dom("b", "read"), " the current table."); + case AccessLevel.full: return cssConfirmLine("Widget needs a ", dom("b", "full access"), " to this document."); + default: throw new Error(`Unsupported ${level} access level`); + } + } // Options for access level. const levels: IOptionFull[] = [ {label: 'No document access', value: AccessLevel.none}, @@ -188,14 +211,15 @@ export class CustomSectionConfig extends Disposable { return dom( 'div', dom.autoDispose(holder), - this._canSelect ? - cssRow( - select(this._selected, options, { - defaultLabel: 'Select Custom Widget', - menuCssClass: cssMenu.className - }), - testId('select') - ) : null, + this._canSelect + ? cssRow( + select(this._selectedId, options, { + defaultLabel: 'Select Custom Widget', + menuCssClass: cssMenu.className, + }), + testId('select') + ) + : null, dom.maybe(isCustom, () => [ cssRow( cssTextInput( @@ -206,57 +230,78 @@ export class CustomSectionConfig extends Disposable { ) ), ]), - cssSection( - cssLink( - dom.attr('href', 'https://support.getgrist.com/widget-custom'), - dom.attr('target', '_blank'), - 'Learn more about custom widgets' - ) - ), - dom.maybe((use) => use(isSelected) || !this._canSelect, () => [ - cssLabel('ACCESS LEVEL'), - cssRow(select(this._selectedAccess, levels), testId('access')), - dom.maybe(prompt, () => - kf.prompt( - {tabindex: '-1'}, - cssColumns( - cssWarningWrapper( - icon('Lock'), + dom.maybe(prompt, () => + kf.prompt( + {tabindex: '-1'}, + cssColumns( + cssWarningWrapper(icon('Lock')), + dom( + 'div', + cssConfirmRow( + dom.domComputed(this._desiredAccess, (level) => buildPrompt(level)) ), - dom('div', - cssConfirmRow( - "Approve requested access level?" + cssConfirmRow( + primaryButton( + 'Accept', + testId('access-accept'), + dom.on('click', () => this._accept()) ), - cssConfirmRow( - primaryButton("Accept", - testId('access-accept'), - dom.on('click', () => this._accept())), - basicButton("Reject", - testId('access-reject'), - dom.on('click', () => this._reject())) + basicButton( + 'Reject', + testId('access-reject'), + dom.on('click', () => this._reject()) ) ) ) ) ) - ]) + ), + dom.maybe( + use => use(isSelected) || !this._canSelect, + () => [ + cssLabel('ACCESS LEVEL'), + cssRow(select(this._currentAccess, levels), testId('access')), + ] + ), + dom.maybe(this._hasConfiguration, () => + cssSection( + textButton( + 'Open configuration', + dom.on('click', () => this._openConfiguration()), + testId('open-configuration') + ) + ) + ), + cssSection( + cssLink( + dom.attr('href', 'https://support.getgrist.com/widget-custom'), + dom.attr('target', '_blank'), + 'Learn more about custom widgets' + ) + ), ); } + private _openConfiguration(): void { + allCommands.openWidgetConfiguration.run(); + } + private _accept() { - this._selectedAccess.set(this._desiredAccess.get()); + if (this._desiredAccess.get()) { + this._currentAccess.set(this._desiredAccess.get()!); + } this._reject(); } private _reject() { - this._desiredAccess.set(this._currentAccess.get()); + this._desiredAccess.set(null); } } const cssWarningWrapper = styled('div', ` padding-left: 8px; padding-top: 6px; - --icon-color: ${colors.lightGreen} + --icon-color: ${colors.error} `); const cssColumns = styled('div', ` @@ -269,6 +314,10 @@ const cssConfirmRow = styled('div', ` gap: 8px; `); +const cssConfirmLine = styled('span', ` + white-space: pre-wrap; +`); + const cssSection = styled('div', ` margin: 16px 16px 12px 16px; `); diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 871f5a1a..855553a8 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -337,7 +337,7 @@ export class RightPanel extends Disposable { // In the default url mode, allow picking a url and granting/forbidding // access to data. dom.maybe(use => use(activeSection.customDef.mode) === 'url', - () => dom.create(CustomSectionConfig, activeSection, this._gristDoc.app.topAppModel.api)), + () => dom.create(CustomSectionConfig, activeSection, this._gristDoc)), ]; }), diff --git a/app/client/ui/ViewLayoutMenu.ts b/app/client/ui/ViewLayoutMenu.ts index b04e7197..43c3ac9a 100644 --- a/app/client/ui/ViewLayoutMenu.ts +++ b/app/client/ui/ViewLayoutMenu.ts @@ -45,6 +45,10 @@ export function makeViewLayoutMenu(viewModel: ViewRec, viewSection: ViewSectionR menuItemCmd(allCommands.dataSelectionTabOpen, 'Data selection'), menuDivider(), + dom.maybe((use) => use(viewSection.parentKey) === 'custom' && use(viewSection.hasCustomOptions), () => + menuItemCmd(allCommands.openWidgetConfiguration, 'Open configuration', + testId('section-open-configuration')), + ), menuItemCmd(allCommands.deleteSection, 'Delete widget', dom.cls('disabled', viewModel.viewSections().peekLength <= 1 || isReadonly), testId('section-delete')), diff --git a/app/client/ui/ViewSectionMenu.ts b/app/client/ui/ViewSectionMenu.ts index 32b0b1ce..7b2f8f05 100644 --- a/app/client/ui/ViewSectionMenu.ts +++ b/app/client/ui/ViewSectionMenu.ts @@ -19,30 +19,39 @@ const testId = makeTestId('test-section-menu-'); const TOOLTIP_DELAY_OPEN = 750; +// Handler for [Save] button. async function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise { await docModel.docData.bundleActions("Update Sort&Filter settings", () => Promise.all([ - viewSection.activeSortJson.save(), // Save sort - viewSection.saveFilters(), // Save filter - viewSection.activeFilterBar.save(), // Save bar + viewSection.activeSortJson.save(), // Save sort + viewSection.saveFilters(), // Save filter + viewSection.activeFilterBar.save(), // Save bar + viewSection.activeCustomOptions.save(), // Save widget options ])); } +// Handler for [Revert] button. function doRevert(viewSection: ViewSectionRec) { - viewSection.activeSortJson.revert(); // Revert sort - viewSection.revertFilters(); // Revert filter - viewSection.activeFilterBar.revert(); // Revert bar + viewSection.activeSortJson.revert(); // Revert sort + viewSection.revertFilters(); // Revert filter + viewSection.activeFilterBar.revert(); // Revert bar + viewSection.activeCustomOptions.revert(); // Revert widget options } +// [Filter Icon] (v) (x) - Filter toggle and all the components in the menu. export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, viewSection: ViewSectionRec, viewModel: ViewRec, isReadonly: Observable) { const popupControls = new WeakMap(); + + // If there is any filter (should [Filter Icon] be green). const anyFilter = Computed.create(owner, (use) => Boolean(use(viewSection.activeFilters).length)); + // Should border be green, and should we show [Save] [Revert] (v) (x) buttons. const displaySaveObs: Computed = Computed.create(owner, (use) => ( use(viewSection.filterSpecChanged) || !use(viewSection.activeSortJson.isSaved) || !use(viewSection.activeFilterBar.isSaved) + || !use(viewSection.activeCustomOptions.isSaved) )); const save = () => { doSave(docModel, viewSection).catch(reportError); }; @@ -55,21 +64,32 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie testId('wrapper'), cssMenu( testId('sortAndFilter'), + // [Filter icon] grey or green cssFilterIconWrapper( testId('filter-icon'), + // Make green when there are some filters. If there are only sort options, leave grey. cssFilterIconWrapper.cls('-any', anyFilter), cssFilterIcon('Filter') ), menu(ctl => [ + // Sorted by section. dom.domComputed(use => { use(viewSection.activeSortJson.isSaved); // Rebuild sort panel if sort gets saved. A little hacky. return makeSortPanel(viewSection, use(viewSection.activeSortSpec), (row: number) => docModel.columns.getRowModel(row)); }), + // Filtered by section. dom.domComputed(viewSection.activeFilters, filters => makeFilterPanel(viewSection, filters, popupControls, () => ctl.close())), + // [+] Add filter makeAddFilterButton(viewSection, popupControls), + // [+] Toggle filter bar makeFilterBarToggle(viewSection.activeFilterBar), + // Widget options + dom.maybe(use => use(viewSection.customDef.mode) === 'url', () => + makeCustomOptions(viewSection) + ), + // [Save] [Revert] buttons dom.domComputed(displaySaveObs, displaySave => [ displaySave ? cssMenuInfoHeader( cssSaveButton('Save', testId('btn-save'), @@ -81,7 +101,10 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie ]), ]), ), + // Two icons (v) (x) left to the toggle, when there are unsaved filters or sort options. + // Those buttons are equivalent of the [Save] [Revert] buttons in the menu. dom.maybe(displaySaveObs, () => cssSaveIconsWrapper( + // (v) cssSmallIconWrapper( cssIcon('Tick'), cssSmallIconWrapper.cls('-green'), dom.on('click', save), @@ -89,6 +112,7 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie testId('small-btn-save'), dom.hide(isReadonly), ), + // (x) cssSmallIconWrapper( cssIcon('CrossSmall'), cssSmallIconWrapper.cls('-gray'), dom.on('click', revert), @@ -106,6 +130,7 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie ]; } +// Sorted by section (and all columns underneath or (Default) label). function makeSortPanel(section: ViewSectionRec, sortSpec: Sort.SortSpec, getColumn: (row: number) => ColumnRec) { const changedColumns = difference(sortSpec, Sort.parseSortColRefs(section.sortColRefs.peek())); const sortColumns = sortSpec.map(colSpec => { @@ -140,6 +165,7 @@ function makeSortPanel(section: ViewSectionRec, sortSpec: Sort.SortSpec, getColu ]; } +// [+] Add Filter. export function makeAddFilterButton(viewSectionRec: ViewSectionRec, popupControls: WeakMap) { return dom.domComputed((use) => { const filters = use(viewSectionRec.filters); @@ -160,6 +186,7 @@ export function makeAddFilterButton(viewSectionRec: ViewSectionRec, popupControl }); } +// [v] or [x] Toggle Filter Bar. export function makeFilterBarToggle(activeFilterBar: CustomComputed) { return cssMenuText( cssMenuIconWrapper( @@ -178,7 +205,7 @@ export function makeFilterBarToggle(activeFilterBar: CustomComputed) { ); } - +// Filtered by - section in the menu (contains all filtered columns or (Not filtered) label). function makeFilterPanel(section: ViewSectionRec, activeFilters: FilterInfo[], popupControls: WeakMap, onCloseContent: () => void) { @@ -213,6 +240,38 @@ function makeFilterPanel(section: ViewSectionRec, activeFilters: FilterInfo[], ]; } + +// Custom Options +// (empty)|(customized)|(modified) [Remove Icon] +function makeCustomOptions(section: ViewSectionRec) { + const color = Computed.create(null, use => use(section.activeCustomOptions.isSaved) ? "-gray" : "-green"); + const text = Computed.create(null, use => { + if (use(section.activeCustomOptions)) { + return use(section.activeCustomOptions.isSaved) ? "(customized)" : "(modified)"; + } else { + return "(empty)"; + } + }); + return [ + cssMenuInfoHeader('Custom options', testId('heading-widget-options')), + cssMenuText( + dom.autoDispose(text), + dom.autoDispose(color), + dom.text(text), + cssMenuText.cls(color), + cssSpacer(), + dom.maybe(use => use(section.activeCustomOptions), () => + cssMenuIconWrapper( + cssIcon('Remove', testId('btn-remove-options'), dom.on('click', () => + section.activeCustomOptions(null) + )) + ), + ), + testId("custom-options") + ) + ]; +} + const clsOldUI = styled('div', ``); const cssFixHeight = styled('div', ` @@ -328,11 +387,16 @@ const cssMenuText = styled('div', ` padding: 0px 24px 8px 24px; cursor: default; white-space: nowrap; + &-green { + color: ${colors.lightGreen}; + } + &-gray { + color: ${colors.slate}; + } `); const cssGrayedMenuText = styled(cssMenuText, ` color: ${colors.slate}; - padding-left: 24px; `); const cssMenuTextLabel = styled('span', ` @@ -363,9 +427,12 @@ const cssSmallIconWrapper = styled('div', ` } `); - const cssSaveIconsWrapper = styled('div', ` padding: 0 1px 0 1px; display: flex; justify-content: space-between; `); + +const cssSpacer = styled('div', ` + margin: 0 auto; +`); diff --git a/app/common/CustomWidget.ts b/app/common/CustomWidget.ts index 0c091519..0de53566 100644 --- a/app/common/CustomWidget.ts +++ b/app/common/CustomWidget.ts @@ -37,3 +37,15 @@ export enum AccessLevel { */ full = "full", } + +export function isSatisfied(current: AccessLevel, minimum: AccessLevel) { + function ordered(level: AccessLevel) { + switch(level) { + case AccessLevel.none: return 0; + case AccessLevel.read_table: return 1; + case AccessLevel.full: return 2; + default: throw new Error(`Unrecognized access level ${level}`); + } + } + return ordered(current) >= ordered(minimum); +} diff --git a/app/plugin/CustomSectionAPI-ti.ts b/app/plugin/CustomSectionAPI-ti.ts index fe8c5d37..d0b21a48 100644 --- a/app/plugin/CustomSectionAPI-ti.ts +++ b/app/plugin/CustomSectionAPI-ti.ts @@ -4,11 +4,22 @@ import * as t from "ts-interface-checker"; // tslint:disable:object-literal-key-quotes +export const RequestedInteractionOptions = t.iface([], { + "requiredAccess": t.opt("string"), + "hasCustomOptions": t.opt("boolean"), +}); + +export const InteractionOptions = t.iface([], { + "accessLevel": "string", +}); + export const CustomSectionAPI = t.iface([], { - "createSection": t.func("void", t.param("inlineTarget", "RenderTarget")), + "configure": t.func("void", t.param("customOptions", "RequestedInteractionOptions")), }); const exportedTypeSuite: t.ITypeSuite = { + RequestedInteractionOptions, + InteractionOptions, CustomSectionAPI, }; export default exportedTypeSuite; diff --git a/app/plugin/CustomSectionAPI.ts b/app/plugin/CustomSectionAPI.ts index 91920da1..705d9934 100644 --- a/app/plugin/CustomSectionAPI.ts +++ b/app/plugin/CustomSectionAPI.ts @@ -2,9 +2,32 @@ * API definitions for CustomSection plugins. */ +/** + * Initial message sent by the CustomWidget with initial requirements. + */ +export interface InteractionOptionsRequest { + /** + * Required access level. If it wasn't granted already, Grist will prompt user to change the current access + * level. + */ + requiredAccess?: string, + /** + * Instructs Grist to show additional menu options that will trigger onEditOptions callback, that Widget + * can use to show custom options screen. + */ + hasCustomOptions?: boolean, +} -import {RenderTarget} from './RenderOptions'; +/** + * Widget configuration set and approved by Grist, sent as part of ready message. + */ +export interface InteractionOptions { + /** + * Granted access level. + */ + accessLevel: string +} export interface CustomSectionAPI { - createSection(inlineTarget: RenderTarget): Promise; + configure(customOptions: InteractionOptionsRequest): Promise; } diff --git a/app/plugin/TypeCheckers.ts b/app/plugin/TypeCheckers.ts index 0e42a20f..0174c2de 100644 --- a/app/plugin/TypeCheckers.ts +++ b/app/plugin/TypeCheckers.ts @@ -7,17 +7,18 @@ import ImportSourceAPITI from './ImportSourceAPI-ti'; import InternalImportSourceAPITI from './InternalImportSourceAPI-ti'; import RenderOptionsTI from './RenderOptions-ti'; import StorageAPITI from './StorageAPI-ti'; +import WidgetAPITI from './WidgetAPI-ti'; /** * The ts-interface-checker type suites are all exported with the "TI" suffix. */ export { CustomSectionAPITI, FileParserAPITI, GristAPITI, GristTableTI, ImportSourceAPITI, - InternalImportSourceAPITI, RenderOptionsTI, StorageAPITI}; + InternalImportSourceAPITI, RenderOptionsTI, StorageAPITI, WidgetAPITI}; const allTypes = [ CustomSectionAPITI, FileParserAPITI, GristAPITI, GristTableTI, ImportSourceAPITI, - InternalImportSourceAPITI, RenderOptionsTI, StorageAPITI]; + InternalImportSourceAPITI, RenderOptionsTI, StorageAPITI, WidgetAPITI]; function checkDuplicates(types: Array<{[key: string]: object}>) { const seen = new Set(); @@ -46,5 +47,5 @@ export const checkers = createCheckers(...allTypes) as ( 'FileSource' | 'ParseFileResult' | 'ComponentKind' | 'GristAPI' | 'GristDocAPI' | 'GristTable' | 'GristTables' | 'GristColumn' | 'GristView' | 'ImportSourceAPI' | 'ImportProcessorAPI' | 'FileContent' | 'FileListItem' | 'URL' | 'ImportSource' | 'InternalImportSourceAPI' | 'RenderTarget' | - 'RenderOptions' | 'Storage' + 'RenderOptions' | 'Storage' | 'WidgetAPI' >); diff --git a/app/plugin/WidgetAPI-ti.ts b/app/plugin/WidgetAPI-ti.ts new file mode 100644 index 00000000..30f65510 --- /dev/null +++ b/app/plugin/WidgetAPI-ti.ts @@ -0,0 +1,20 @@ +/** + * This module was automatically generated by `ts-interface-builder` + */ +import * as t from "ts-interface-checker"; +// tslint:disable:object-literal-key-quotes + +export const WidgetAPI = t.iface([], { + "getOptions": t.func(t.union("object", "null")), + "setOptions": t.func("void", t.param("options", t.iface([], { + [t.indexKey]: "any", + }))), + "clearOptions": t.func("void"), + "setOption": t.func("void", t.param("key", "string"), t.param("value", "any")), + "getOption": t.func("any", t.param("key", "string")), +}); + +const exportedTypeSuite: t.ITypeSuite = { + WidgetAPI, +}; +export default exportedTypeSuite; diff --git a/app/plugin/WidgetAPI.ts b/app/plugin/WidgetAPI.ts new file mode 100644 index 00000000..c32f27bf --- /dev/null +++ b/app/plugin/WidgetAPI.ts @@ -0,0 +1,25 @@ +/** + * API to manage Custom Widget state. + */ +export interface WidgetAPI { + /** + * Gets all options stored by the widget. Options are stored as plain JSON object. + */ + getOptions(): Promise; + /** + * Replaces all options stored by the widget. + */ + setOptions(options: {[key: string]: any}): Promise; + /** + * Clears all the options. + */ + clearOptions(): Promise; + /** + * Store single value in the Widget options object (and create it if necessary). + */ + setOption(key: string, value: any): Promise; + /** + * Get single value from Widget options object. + */ + getOption(key: string): Promise; +} diff --git a/app/plugin/grist-plugin-api.ts b/app/plugin/grist-plugin-api.ts index 0812753c..c4b93c75 100644 --- a/app/plugin/grist-plugin-api.ts +++ b/app/plugin/grist-plugin-api.ts @@ -18,12 +18,14 @@ // tslint:disable:no-console +import { CustomSectionAPI, InteractionOptions } from './CustomSectionAPI'; import { GristAPI, GristDocAPI, GristView, RPC_GRISTAPI_INTERFACE } from './GristAPI'; import { RowRecord } from './GristData'; import { ImportSource, ImportSourceAPI, InternalImportSourceAPI } from './InternalImportSourceAPI'; import { decodeObject, mapValues } from './objtypes'; import { RenderOptions, RenderTarget } from './RenderOptions'; import { checkers } from './TypeCheckers'; +import { WidgetAPI } from './WidgetAPI'; export * from './TypeCheckers'; export * from './FileParserAPI'; @@ -32,6 +34,8 @@ export * from './GristTable'; export * from './ImportSourceAPI'; export * from './StorageAPI'; export * from './RenderOptions'; +export * from './WidgetAPI'; +export * from './CustomSectionAPI'; import {IRpcLogger, Rpc} from 'grain-rpc'; @@ -40,6 +44,8 @@ export const rpc: Rpc = new Rpc({logger: createRpcLogger()}); export const api = rpc.getStub(RPC_GRISTAPI_INTERFACE, checkers.GristAPI); export const coreDocApi = rpc.getStub('GristDocAPI@grist', checkers.GristDocAPI); export const viewApi = rpc.getStub('GristView', checkers.GristView); +export const widgetApi = rpc.getStub('WidgetAPI', checkers.WidgetAPI); +export const sectionApi = rpc.getStub('CustomSectionAPI', checkers.CustomSectionAPI); export const docApi: GristDocAPI & GristView = { ...coreDocApi, @@ -98,11 +104,24 @@ export function onRecords(callback: (data: RowRecord[]) => unknown) { }); } + +// For custom widgets, add a handler that will be called whenever the +// widget options change (and on initial ready message). Handler will be +// called with an object containing save json options, or null if no options were saved. +// Second parameter +export function onOptions(callback: (options: any, settings: InteractionOptions) => unknown) { + on('message', async function(msg) { + if (msg.settings) { + callback(msg.options || null, msg.settings); + } + }); +} + /** * Calling `addImporter(...)` adds a safeBrowser importer. It is a short-hand for forwarding calls * to an `ImportSourceAPI` implementation registered in the file at `path`. It takes care of * creating the stub, registering an implementation that renders the file, forward the call and - * dispose the view properly. If `mode` is `'inline'` embeds the view in the import modal, ohterwise + * dispose the view properly. If `mode` is `'inline'` embeds the view in the import modal, otherwise * renders fullscreen. * * Notes: it assumes that file at `path` registers an `ImportSourceAPI` implementation under @@ -111,7 +130,7 @@ export function onRecords(callback: (data: RowRecord[]) => unknown) { * */ export async function addImporter(name: string, path: string, mode: 'fullscreen' | 'inline', options?: RenderOptions) { - // checker is omitterd for implementation because call was alredy checked by grist. + // checker is omitted for implementation because call was alredy checked by grist. rpc.registerImpl(name, { async getImportSource(target: RenderTarget): Promise { const procId = await api.render(path, mode === 'inline' ? target : 'fullscreen', options); @@ -131,9 +150,23 @@ export async function addImporter(name: string, path: string, mode: 'fullscreen' * Declare that a component is prepared to receive messages from the outside world. * Grist will not attempt to communicate with it until this method is called. */ -export function ready(): void { +export function ready(settings?: { + requiredAccess?: string, + onEditOptions: () => unknown +}): void { + if (settings && settings.onEditOptions) { + rpc.registerFunc('editOptions', settings.onEditOptions); + } rpc.processIncoming(); - void rpc.sendReadyMessage(); + void (async function() { + await rpc.sendReadyMessage(); + if (settings) { + await sectionApi.configure({ + requiredAccess : settings.requiredAccess, + hasCustomOptions: Boolean(settings.onEditOptions) + }).catch((err: unknown) => console.error(err)); + } + })(); } function getPluginPath(location: Location) { diff --git a/app/server/lib/WidgetRepository.ts b/app/server/lib/WidgetRepository.ts index 00c3544e..d3e58c96 100644 --- a/app/server/lib/WidgetRepository.ts +++ b/app/server/lib/WidgetRepository.ts @@ -68,6 +68,10 @@ export class WidgetRepositoryImpl implements IWidgetRepository { class CachedWidgetRepository extends WidgetRepositoryImpl { private _cache = new LRUCache<1, ICustomWidget[]>({maxAge : 1000 * 60 /* minute */ * 2}); public async getWidgets() { + // Don't cache for localhost + if (super._staticUrl && super._staticUrl.startsWith("http://localhost")) { + this._cache.reset(); + } if (this._cache.has(1)) { log.debug("WidgetRepository: Widget list taken from the cache."); return this._cache.get(1)!; diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 1e07ecc1..80575cc3 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1067,6 +1067,13 @@ export async function sendKeys(...keys: string[]) { }); } +/** + * Clears active input by sending HOME + SHIFT END + DELETE. + */ +export async function clearInput() { + return sendKeys(Key.HOME, Key.chord(Key.SHIFT, Key.END), Key.DELETE); +} + /** * Open ⋮ dropdown menu for named workspace. */