mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Widget options api
Summary: Adding configuration options for CustomWidgets. Custom widgets can now store options (in JSON) in viewSection metadata. Changes in grist-plugin-api: - Adding onOptions handler, that will be invoked when the widget is ready and when the configuration is changed - Adding WidgetAPI - new API to read and save a configuration for widget. Changes in Grist: - Rewriting CustomView code, and extracting code that is responsible for showing the iframe and registering Rpc. - Adding Open Configuration button to Widget section in the Creator panel and in the section menu. - Custom Widgets can implement "configure" method, to show configuration screen when requested. Test Plan: Browser tests. Reviewers: paulfitz, dsagal Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3185
This commit is contained in:
parent
5a876976d5
commit
85ef873ce5
@ -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;
|
||||
}
|
||||
|
||||
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>('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>('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});
|
||||
}
|
||||
});
|
||||
} 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;
|
||||
this.viewSection.desiredAccessLevel(access);
|
||||
}
|
||||
|
||||
private listenTo(...args: any[]): void { /* replaced by Backbone */ }
|
||||
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);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
554
app/client/components/WidgetFrame.ts
Normal file
554
app/client/components/WidgetFrame.ts
Normal file
@ -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<T extends object>(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<T> = 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<GristDocAPI>()
|
||||
* .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<GristDocAPI>()
|
||||
* .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<GristDocAPI>()
|
||||
* .require("none", "method2") // for method2 we are ok with none access
|
||||
* .require("read_table", "*") // for any other, require read_table
|
||||
*/
|
||||
export class MethodAccess<T> implements AccessChecker {
|
||||
private _accessMap: Map<MethodMatcher<T>, AccessLevel> = new Map();
|
||||
constructor() {}
|
||||
public require(level: AccessLevel, method: MethodMatcher<T> = '*') {
|
||||
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<T>)) {
|
||||
// If it was, check that minimum access level is granted.
|
||||
const minimum = this._accessMap.get(method as MethodMatcher<T>)!;
|
||||
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<GristDocAPI>()
|
||||
.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<string[]> {
|
||||
// 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<any> {
|
||||
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<any> {
|
||||
// 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<void> {
|
||||
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<Record<string, unknown> | null> {
|
||||
console.debug(`getOptions`);
|
||||
return this._section.activeCustomOptions.peek() ?? null;
|
||||
}
|
||||
|
||||
public async clearOptions(): Promise<void> {
|
||||
console.debug(`clearOptions`);
|
||||
this._section.activeCustomOptions(null);
|
||||
}
|
||||
|
||||
public async setOption(key: string, value: any): Promise<void> {
|
||||
console.debug(`setOption(${key}, ${value})`);
|
||||
const options = {...this._section.activeCustomOptions.peek()};
|
||||
options[key] = value;
|
||||
this._section.activeCustomOptions(options);
|
||||
}
|
||||
|
||||
public getOption(key: string): Promise<unknown> {
|
||||
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<any | null>;
|
||||
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<void> {
|
||||
if (settings.hasCustomOptions !== undefined) {
|
||||
this._section.hasCustomOptions(settings.hasCustomOptions);
|
||||
}
|
||||
if (settings.requiredAccess && settings.requiredAccess !== this._currentAccess) {
|
||||
this._promptCallback(settings.requiredAccess as AccessLevel);
|
||||
}
|
||||
}
|
||||
}
|
@ -97,6 +97,11 @@ exports.groups = [{
|
||||
name: 'printSection',
|
||||
keys: [],
|
||||
desc: 'Print currently selected page widget',
|
||||
},
|
||||
{
|
||||
name: 'openWidgetConfiguration',
|
||||
keys: [],
|
||||
desc: 'Open Custom widget configuration screen',
|
||||
}
|
||||
]
|
||||
}, {
|
||||
|
2
app/client/declarations.d.ts
vendored
2
app/client/declarations.d.ts
vendored
@ -61,7 +61,7 @@ declare module "app/client/components/BaseView" {
|
||||
public activeFieldBuilder: ko.Computed<FieldBuilder>;
|
||||
public disableEditing: ko.Computed<boolean>;
|
||||
public isTruncated: ko.Observable<boolean>;
|
||||
protected tableModel: DataTableModel;
|
||||
public tableModel: DataTableModel;
|
||||
|
||||
constructor(gristDoc: GristDoc, viewSectionModel: any);
|
||||
public setCursorPos(cursorPos: CursorPos): void;
|
||||
|
@ -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<number>;
|
||||
activeCustomOptions: modelUtil.CustomComputed<any>;
|
||||
// Temporary variable holding flag that describes if the widget supports custom options (set by api).
|
||||
hasCustomOptions: ko.Observable<boolean>;
|
||||
// Temporary variable holding widget desired access (changed either from manifest or via api).
|
||||
desiredAccessLevel: ko.Observable<AccessLevel|null>;
|
||||
|
||||
// Save all filters of fields/columns in the section.
|
||||
saveFilters(): Promise<void>;
|
||||
@ -150,6 +155,10 @@ export interface CustomViewSectionDef {
|
||||
* Custom widget information.
|
||||
*/
|
||||
widgetDef: modelUtil.KoSaveableObservable<ICustomWidget|null>;
|
||||
/**
|
||||
* Custom widget options.
|
||||
*/
|
||||
widgetOptions: modelUtil.KoSaveableObservable<Record<string, any>|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);
|
||||
}
|
||||
|
@ -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<ICustomWidget[]>;
|
||||
// Holds selected option (either custom or a widgetId).
|
||||
private _selected: Computed<string|null>;
|
||||
// Holds selected option (either custom string or a widgetId).
|
||||
private _selectedId: Computed<string | null>;
|
||||
// Holds custom widget URL.
|
||||
private _url: Computed<string>;
|
||||
// Enable or disable widget repository.
|
||||
private _canSelect = true;
|
||||
// Selected access level.
|
||||
private _selectedAccess: Computed<AccessLevel>;
|
||||
// When widget is changed, it sets its desired access level. We will prompt
|
||||
// user to approve or reject it.
|
||||
private _desiredAccess: Observable<AccessLevel>;
|
||||
private _desiredAccess: Observable<AccessLevel|null>;
|
||||
// Current access level (stored inside a section).
|
||||
private _currentAccess: Computed<AccessLevel>;
|
||||
// Does widget has custom configuration.
|
||||
private _hasConfiguration: Computed<boolean>;
|
||||
|
||||
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<string>[] = [
|
||||
{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,6 +230,48 @@ export class CustomSectionConfig extends Disposable {
|
||||
)
|
||||
),
|
||||
]),
|
||||
dom.maybe(prompt, () =>
|
||||
kf.prompt(
|
||||
{tabindex: '-1'},
|
||||
cssColumns(
|
||||
cssWarningWrapper(icon('Lock')),
|
||||
dom(
|
||||
'div',
|
||||
cssConfirmRow(
|
||||
dom.domComputed(this._desiredAccess, (level) => buildPrompt(level))
|
||||
),
|
||||
cssConfirmRow(
|
||||
primaryButton(
|
||||
'Accept',
|
||||
testId('access-accept'),
|
||||
dom.on('click', () => this._accept())
|
||||
),
|
||||
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'),
|
||||
@ -213,50 +279,29 @@ export class CustomSectionConfig extends Disposable {
|
||||
'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('div',
|
||||
cssConfirmRow(
|
||||
"Approve requested access level?"
|
||||
),
|
||||
cssConfirmRow(
|
||||
primaryButton("Accept",
|
||||
testId('access-accept'),
|
||||
dom.on('click', () => this._accept())),
|
||||
basicButton("Reject",
|
||||
testId('access-reject'),
|
||||
dom.on('click', () => this._reject()))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
`);
|
||||
|
@ -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)),
|
||||
];
|
||||
}),
|
||||
|
||||
|
@ -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')),
|
||||
|
@ -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<void> {
|
||||
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<boolean>) {
|
||||
|
||||
const popupControls = new WeakMap<ColumnRec, PopupControl>();
|
||||
|
||||
// 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<boolean> = 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<ColumnRec, PopupControl>) {
|
||||
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<boolean>) {
|
||||
return cssMenuText(
|
||||
cssMenuIconWrapper(
|
||||
@ -178,7 +205,7 @@ export function makeFilterBarToggle(activeFilterBar: CustomComputed<boolean>) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Filtered by - section in the menu (contains all filtered columns or (Not filtered) label).
|
||||
function makeFilterPanel(section: ViewSectionRec, activeFilters: FilterInfo[],
|
||||
popupControls: WeakMap<ColumnRec, PopupControl>,
|
||||
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;
|
||||
`);
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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<void>;
|
||||
configure(customOptions: InteractionOptionsRequest): Promise<void>;
|
||||
}
|
||||
|
@ -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<string>();
|
||||
@ -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'
|
||||
>);
|
||||
|
20
app/plugin/WidgetAPI-ti.ts
Normal file
20
app/plugin/WidgetAPI-ti.ts
Normal file
@ -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;
|
25
app/plugin/WidgetAPI.ts
Normal file
25
app/plugin/WidgetAPI.ts
Normal file
@ -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<object | null>;
|
||||
/**
|
||||
* Replaces all options stored by the widget.
|
||||
*/
|
||||
setOptions(options: {[key: string]: any}): Promise<void>;
|
||||
/**
|
||||
* Clears all the options.
|
||||
*/
|
||||
clearOptions(): Promise<void>;
|
||||
/**
|
||||
* Store single value in the Widget options object (and create it if necessary).
|
||||
*/
|
||||
setOption(key: string, value: any): Promise<void>;
|
||||
/**
|
||||
* Get single value from Widget options object.
|
||||
*/
|
||||
getOption(key: string): Promise<any>;
|
||||
}
|
@ -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<GristAPI>(RPC_GRISTAPI_INTERFACE, checkers.GristAPI);
|
||||
export const coreDocApi = rpc.getStub<GristDocAPI>('GristDocAPI@grist', checkers.GristDocAPI);
|
||||
export const viewApi = rpc.getStub<GristView>('GristView', checkers.GristView);
|
||||
export const widgetApi = rpc.getStub<WidgetAPI>('WidgetAPI', checkers.WidgetAPI);
|
||||
export const sectionApi = rpc.getStub<CustomSectionAPI>('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<InternalImportSourceAPI>(name, {
|
||||
async getImportSource(target: RenderTarget): Promise<ImportSource|undefined> {
|
||||
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) {
|
||||
|
@ -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)!;
|
||||
|
@ -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.
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user