mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Support dark mode in custom widgets
Test Plan: Manual. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4036
This commit is contained in:
parent
ffbf93b85f
commit
4c25aa7d3d
@ -153,8 +153,14 @@ export class CustomView extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _buildDom() {
|
private _buildDom() {
|
||||||
const {mode, url, access} = this.customDef;
|
const {mode, url, access, renderAfterReady} = this.customDef;
|
||||||
const showPlugin = ko.pureComputed(() => this.customDef.mode() === "plugin");
|
const showPlugin = ko.pureComputed(() => this.customDef.mode() === "plugin");
|
||||||
|
const showAfterReady = () => {
|
||||||
|
// The empty widget page calls `grist.ready()`.
|
||||||
|
if (!url()) { return true; }
|
||||||
|
|
||||||
|
return this.customDef.widgetDef()?.renderAfterReady ?? renderAfterReady();
|
||||||
|
};
|
||||||
|
|
||||||
// When both plugin and section are not found, let's show only plugin notification.
|
// When both plugin and section are not found, let's show only plugin notification.
|
||||||
const showPluginNotification = ko.pureComputed(() => showPlugin() && !this._foundPlugin());
|
const showPluginNotification = ko.pureComputed(() => showPlugin() && !this._foundPlugin());
|
||||||
@ -170,7 +176,14 @@ export class CustomView extends Disposable {
|
|||||||
dom.autoDispose(showPluginContent),
|
dom.autoDispose(showPluginContent),
|
||||||
// todo: should display content in webview when running electron
|
// todo: should display content in webview when running electron
|
||||||
kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) =>
|
kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) =>
|
||||||
_mode === "url" ? this._buildIFrame(_url, (_access || AccessLevel.none) as AccessLevel) : null),
|
_mode === "url" ?
|
||||||
|
this._buildIFrame({
|
||||||
|
baseUrl: _url,
|
||||||
|
access: (_access as AccessLevel || AccessLevel.none),
|
||||||
|
showAfterReady: showAfterReady(),
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
),
|
||||||
kd.maybe(showPluginNotification, () => buildNotification('Plugin ',
|
kd.maybe(showPluginNotification, () => buildNotification('Plugin ',
|
||||||
dom('strong', kd.text(this.customDef.pluginId)), ' was not found',
|
dom('strong', kd.text(this.customDef.pluginId)), ' was not found',
|
||||||
dom.testId('customView_notification_plugin')
|
dom.testId('customView_notification_plugin')
|
||||||
@ -193,11 +206,22 @@ export class CustomView extends Disposable {
|
|||||||
this.viewSection.desiredAccessLevel(access);
|
this.viewSection.desiredAccessLevel(access);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _buildIFrame(baseUrl: string, access: AccessLevel) {
|
private _buildIFrame(options: {
|
||||||
|
baseUrl: string|null,
|
||||||
|
access: AccessLevel,
|
||||||
|
showAfterReady?: boolean,
|
||||||
|
}) {
|
||||||
|
const {baseUrl, access, showAfterReady} = options;
|
||||||
return grains.create(WidgetFrame, {
|
return grains.create(WidgetFrame, {
|
||||||
url: baseUrl || this.getEmptyWidgetPage(),
|
url: baseUrl || this.getEmptyWidgetPage(),
|
||||||
access,
|
access,
|
||||||
readonly: this.gristDoc.isReadonly.get(),
|
readonly: this.gristDoc.isReadonly.get(),
|
||||||
|
showAfterReady,
|
||||||
|
onSettingsInitialized: async () => {
|
||||||
|
if (!this.customDef.renderAfterReady.peek()) {
|
||||||
|
await this.customDef.renderAfterReady.setAndSave(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
configure: (frame) => {
|
configure: (frame) => {
|
||||||
this._frame = frame;
|
this._frame = frame;
|
||||||
// Need to cast myself to a BaseView
|
// Need to cast myself to a BaseView
|
||||||
@ -223,7 +247,10 @@ export class CustomView extends Disposable {
|
|||||||
new WidgetAPIImpl(this.viewSection),
|
new WidgetAPIImpl(this.viewSection),
|
||||||
new MinimumLevel(AccessLevel.none)); // none access is enough
|
new MinimumLevel(AccessLevel.none)); // none access is enough
|
||||||
frame.useEvents(
|
frame.useEvents(
|
||||||
ConfigNotifier.create(frame, this.viewSection, access),
|
ConfigNotifier.create(frame, this.viewSection, {
|
||||||
|
access,
|
||||||
|
theme: this.gristDoc.currentTheme,
|
||||||
|
}),
|
||||||
new MinimumLevel(AccessLevel.none)); // none access is enough
|
new MinimumLevel(AccessLevel.none)); // none access is enough
|
||||||
},
|
},
|
||||||
onElem: (iframe) => onFrameFocus(iframe, () => {
|
onElem: (iframe) => onFrameFocus(iframe, () => {
|
||||||
|
@ -224,8 +224,13 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
this.docData = new DocData(this.docComm, openDocResponse.doc);
|
this.docData = new DocData(this.docComm, openDocResponse.doc);
|
||||||
this.docModel = new DocModel(this.docData, this.docPageModel);
|
this.docModel = new DocModel(this.docData, this.docPageModel);
|
||||||
this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm);
|
this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm);
|
||||||
this.docPluginManager = new DocPluginManager(plugins,
|
this.docPluginManager = new DocPluginManager({
|
||||||
app.topAppModel.getUntrustedContentOrigin(), this.docComm, app.clientScope);
|
plugins,
|
||||||
|
untrustedContentOrigin: app.topAppModel.getUntrustedContentOrigin(),
|
||||||
|
docComm: this.docComm,
|
||||||
|
clientScope: app.clientScope,
|
||||||
|
theme: this.currentTheme,
|
||||||
|
});
|
||||||
|
|
||||||
// Maintain the MetaRowModel for the global document info, including docId and peers.
|
// Maintain the MetaRowModel for the global document info, including docId and peers.
|
||||||
this.docInfo = this.docModel.docInfoRow;
|
this.docInfo = this.docModel.docInfoRow;
|
||||||
|
@ -8,6 +8,7 @@ import {AccessLevel, isSatisfied} from 'app/common/CustomWidget';
|
|||||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||||
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
|
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
|
||||||
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
|
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
|
||||||
|
import {Theme} from 'app/common/ThemePrefs';
|
||||||
import {AccessTokenOptions, CursorPos, CustomSectionAPI, GristDocAPI, GristView,
|
import {AccessTokenOptions, CursorPos, CustomSectionAPI, GristDocAPI, GristView,
|
||||||
InteractionOptionsRequest, WidgetAPI, WidgetColumnMap} from 'app/plugin/grist-plugin-api';
|
InteractionOptionsRequest, WidgetAPI, WidgetColumnMap} from 'app/plugin/grist-plugin-api';
|
||||||
import {MsgType, Rpc} from 'grain-rpc';
|
import {MsgType, Rpc} from 'grain-rpc';
|
||||||
@ -48,6 +49,20 @@ export interface WidgetFrameOptions {
|
|||||||
* If document is in readonly mode.
|
* If document is in readonly mode.
|
||||||
*/
|
*/
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
|
/**
|
||||||
|
* If set, show the iframe after `grist.ready()`.
|
||||||
|
*
|
||||||
|
* Currently, this is only used to defer showing a widget until it has had
|
||||||
|
* a chance to apply the Grist theme.
|
||||||
|
*/
|
||||||
|
showAfterReady?: boolean;
|
||||||
|
/**
|
||||||
|
* Handler for the settings initialized message.
|
||||||
|
*
|
||||||
|
* Currently, this is only used to defer showing a widget until it has had
|
||||||
|
* a chance to apply the Grist theme.
|
||||||
|
*/
|
||||||
|
onSettingsInitialized: () => void;
|
||||||
/**
|
/**
|
||||||
* Optional callback to configure exposed API.
|
* Optional callback to configure exposed API.
|
||||||
*/
|
*/
|
||||||
@ -68,6 +83,8 @@ export class WidgetFrame extends DisposableWithEvents {
|
|||||||
private _iframe: HTMLIFrameElement | null;
|
private _iframe: HTMLIFrameElement | null;
|
||||||
// If widget called ready() method, this will be set to true.
|
// If widget called ready() method, this will be set to true.
|
||||||
private _readyCalled = Observable.create(this, false);
|
private _readyCalled = Observable.create(this, false);
|
||||||
|
// Whether the iframe is visible.
|
||||||
|
private _visible = Observable.create(this, !this._options.showAfterReady);
|
||||||
|
|
||||||
constructor(private _options: WidgetFrameOptions) {
|
constructor(private _options: WidgetFrameOptions) {
|
||||||
super();
|
super();
|
||||||
@ -162,6 +179,7 @@ export class WidgetFrame extends DisposableWithEvents {
|
|||||||
const onElem = this._options.onElem ?? ((el: HTMLIFrameElement) => el);
|
const onElem = this._options.onElem ?? ((el: HTMLIFrameElement) => el);
|
||||||
return onElem(
|
return onElem(
|
||||||
(this._iframe = dom('iframe',
|
(this._iframe = dom('iframe',
|
||||||
|
dom.style('visibility', use => use(this._visible) ? 'visible' : 'hidden'),
|
||||||
dom.cls('clipboard_focus'),
|
dom.cls('clipboard_focus'),
|
||||||
dom.cls('custom_view'), {
|
dom.cls('custom_view'), {
|
||||||
src: fullUrl,
|
src: fullUrl,
|
||||||
@ -189,6 +207,10 @@ export class WidgetFrame extends DisposableWithEvents {
|
|||||||
this.trigger('ready', this);
|
this.trigger('ready', this);
|
||||||
this._readyCalled.set(true);
|
this._readyCalled.set(true);
|
||||||
}
|
}
|
||||||
|
if (event.data.data?.settings?.status === 'initialized') {
|
||||||
|
this._visible.set(true);
|
||||||
|
this._options.onSettingsInitialized();
|
||||||
|
}
|
||||||
this._rpc.receiveMessage(event.data);
|
this._rpc.receiveMessage(event.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -523,38 +545,48 @@ export class RecordNotifier extends BaseEventSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConfigNotifierOptions {
|
||||||
|
access: AccessLevel;
|
||||||
|
theme: Computed<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifies about options change. Exposed in the API as a onOptions handler.
|
* Notifies about options change. Exposed in the API as a onOptions handler.
|
||||||
*/
|
*/
|
||||||
export class ConfigNotifier extends BaseEventSource {
|
export class ConfigNotifier extends BaseEventSource {
|
||||||
|
private _accessLevel = this._options.access;
|
||||||
|
private _theme = this._options.theme;
|
||||||
private _currentConfig: Computed<any | null>;
|
private _currentConfig: Computed<any | null>;
|
||||||
private _debounced: () => void; // debounced call to let the view know linked cursor changed.
|
// Debounced call to let the view know linked cursor changed.
|
||||||
constructor(private _section: ViewSectionRec, private _accessLevel: AccessLevel) {
|
private _debounced: (fromReady?: boolean) => void;
|
||||||
|
constructor(private _section: ViewSectionRec, private _options: ConfigNotifierOptions) {
|
||||||
super();
|
super();
|
||||||
this._currentConfig = Computed.create(this, use => {
|
this._currentConfig = Computed.create(this, use => {
|
||||||
const options = use(this._section.activeCustomOptions);
|
const options = use(this._section.activeCustomOptions);
|
||||||
return options;
|
return options;
|
||||||
});
|
});
|
||||||
this._debounced = debounce(() => this._update(), 0);
|
this._debounced = debounce((fromReady?: boolean) => this._update(fromReady), 0);
|
||||||
const subscribe = (obs: Observable<any>) => {
|
const subscribe = (...observables: Observable<any>[]) => {
|
||||||
this.autoDispose(
|
for (const obs of observables) {
|
||||||
obs.addListener((cur, prev) => {
|
this.autoDispose(
|
||||||
if (isEqual(prev, cur)) {
|
obs.addListener((cur, prev) => {
|
||||||
return;
|
if (isEqual(prev, cur)) {
|
||||||
}
|
return;
|
||||||
this._debounced();
|
}
|
||||||
})
|
this._debounced();
|
||||||
);
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
subscribe(this._currentConfig);
|
subscribe(this._currentConfig, this._theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _ready() {
|
protected _ready() {
|
||||||
// On ready, send initial configuration.
|
// On ready, send initial configuration.
|
||||||
this._debounced();
|
this._debounced(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _update() {
|
private _update(fromReady = false) {
|
||||||
if (this.isDisposed()) {
|
if (this.isDisposed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -562,7 +594,9 @@ export class ConfigNotifier extends BaseEventSource {
|
|||||||
options: this._currentConfig.get(),
|
options: this._currentConfig.get(),
|
||||||
settings: {
|
settings: {
|
||||||
accessLevel: this._accessLevel,
|
accessLevel: this._accessLevel,
|
||||||
|
theme: this._theme.get(),
|
||||||
},
|
},
|
||||||
|
fromReady,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,8 @@ import {SafeBrowser} from 'app/client/lib/SafeBrowser';
|
|||||||
import {ActiveDocAPI} from 'app/common/ActiveDocAPI';
|
import {ActiveDocAPI} from 'app/common/ActiveDocAPI';
|
||||||
import {LocalPlugin} from 'app/common/plugin';
|
import {LocalPlugin} from 'app/common/plugin';
|
||||||
import {createRpcLogger, PluginInstance} from 'app/common/PluginInstance';
|
import {createRpcLogger, PluginInstance} from 'app/common/PluginInstance';
|
||||||
|
import {Theme} from 'app/common/ThemePrefs';
|
||||||
|
import {Computed} from 'grainjs';
|
||||||
import {Rpc} from 'grain-rpc';
|
import {Rpc} from 'grain-rpc';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -13,15 +15,31 @@ export class DocPluginManager {
|
|||||||
|
|
||||||
public pluginsList: PluginInstance[];
|
public pluginsList: PluginInstance[];
|
||||||
|
|
||||||
constructor(localPlugins: LocalPlugin[], private _untrustedContentOrigin: string, private _docComm: ActiveDocAPI,
|
private _clientScope = this._options.clientScope;
|
||||||
private _clientScope: ClientScope) {
|
private _docComm = this._options.docComm;
|
||||||
|
private _localPlugins = this._options.plugins;
|
||||||
|
private _theme = this._options.theme;
|
||||||
|
private _untrustedContentOrigin = this._options.untrustedContentOrigin;
|
||||||
|
|
||||||
|
constructor(private _options: {
|
||||||
|
plugins: LocalPlugin[],
|
||||||
|
untrustedContentOrigin: string,
|
||||||
|
docComm: ActiveDocAPI,
|
||||||
|
clientScope: ClientScope,
|
||||||
|
theme: Computed<Theme>,
|
||||||
|
}) {
|
||||||
this.pluginsList = [];
|
this.pluginsList = [];
|
||||||
for (const plugin of localPlugins) {
|
for (const plugin of this._localPlugins) {
|
||||||
try {
|
try {
|
||||||
const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `PLUGIN ${plugin.id}:`));
|
const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `PLUGIN ${plugin.id}:`));
|
||||||
const components = plugin.manifest.components || {};
|
const components = plugin.manifest.components || {};
|
||||||
const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser(pluginInstance,
|
const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser({
|
||||||
this._clientScope, this._untrustedContentOrigin, components.safeBrowser);
|
pluginInstance,
|
||||||
|
clientScope: this._clientScope,
|
||||||
|
untrustedContentOrigin: this._untrustedContentOrigin,
|
||||||
|
mainPath: components.safeBrowser,
|
||||||
|
theme: this._theme,
|
||||||
|
});
|
||||||
if (components.safeBrowser) {
|
if (components.safeBrowser) {
|
||||||
pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);
|
pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);
|
||||||
}
|
}
|
||||||
|
@ -14,8 +14,8 @@ export class HomePluginManager {
|
|||||||
public pluginsList: PluginInstance[];
|
public pluginsList: PluginInstance[];
|
||||||
|
|
||||||
constructor(localPlugins: LocalPlugin[],
|
constructor(localPlugins: LocalPlugin[],
|
||||||
_untrustedContentOrigin: string,
|
untrustedContentOrigin: string,
|
||||||
_clientScope: ClientScope) {
|
clientScope: ClientScope) {
|
||||||
this.pluginsList = [];
|
this.pluginsList = [];
|
||||||
for (const plugin of localPlugins) {
|
for (const plugin of localPlugins) {
|
||||||
try {
|
try {
|
||||||
@ -30,8 +30,12 @@ export class HomePluginManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `HOME PLUGIN ${plugin.id}:`));
|
const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `HOME PLUGIN ${plugin.id}:`));
|
||||||
const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser(pluginInstance,
|
const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser({
|
||||||
_clientScope, _untrustedContentOrigin, components.safeBrowser);
|
pluginInstance,
|
||||||
|
clientScope,
|
||||||
|
untrustedContentOrigin,
|
||||||
|
mainPath: components.safeBrowser,
|
||||||
|
});
|
||||||
if (components.safeBrowser) {
|
if (components.safeBrowser) {
|
||||||
pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);
|
pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);
|
||||||
}
|
}
|
||||||
|
@ -36,12 +36,15 @@ import * as Mousetrap from 'app/client/lib/Mousetrap';
|
|||||||
import { ActionRouter } from 'app/common/ActionRouter';
|
import { ActionRouter } from 'app/common/ActionRouter';
|
||||||
import { BaseComponent, BaseLogger, createRpcLogger, PluginInstance, warnIfNotReady } from 'app/common/PluginInstance';
|
import { BaseComponent, BaseLogger, createRpcLogger, PluginInstance, warnIfNotReady } from 'app/common/PluginInstance';
|
||||||
import { tbind } from 'app/common/tbind';
|
import { tbind } from 'app/common/tbind';
|
||||||
|
import { Theme } from 'app/common/ThemePrefs';
|
||||||
import { getOriginUrl } from 'app/common/urlUtils';
|
import { getOriginUrl } from 'app/common/urlUtils';
|
||||||
import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI';
|
import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI';
|
||||||
import { RenderOptions, RenderTarget } from 'app/plugin/RenderOptions';
|
import { RenderOptions, RenderTarget } from 'app/plugin/RenderOptions';
|
||||||
import { checkers } from 'app/plugin/TypeCheckers';
|
import { checkers } from 'app/plugin/TypeCheckers';
|
||||||
import { IMsgCustom, IMsgRpcCall, Rpc } from 'grain-rpc';
|
import { Computed, dom as grainjsDom, Observable } from 'grainjs';
|
||||||
|
import { IMsgCustom, IMsgRpcCall, IRpcLogger, MsgType, Rpc } from 'grain-rpc';
|
||||||
import { Disposable } from './dispose';
|
import { Disposable } from './dispose';
|
||||||
|
import isEqual from 'lodash/isEqual';
|
||||||
const G = getBrowserGlobals('document', 'window');
|
const G = getBrowserGlobals('document', 'window');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,7 +57,6 @@ const G = getBrowserGlobals('document', 'window');
|
|||||||
// client processes and among other thing will expose both renderImpl and
|
// client processes and among other thing will expose both renderImpl and
|
||||||
// disposeImpl. ClientProcess will hold a reference to ProcessManager instead of SafeBrowser.
|
// disposeImpl. ClientProcess will hold a reference to ProcessManager instead of SafeBrowser.
|
||||||
export class SafeBrowser extends BaseComponent {
|
export class SafeBrowser extends BaseComponent {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a webview ClientProcess to render safe browser process in electron.
|
* Create a webview ClientProcess to render safe browser process in electron.
|
||||||
*/
|
*/
|
||||||
@ -71,6 +73,8 @@ export class SafeBrowser extends BaseComponent {
|
|||||||
new IframeProcess(safeBrowser, rpc, src);
|
new IframeProcess(safeBrowser, rpc, src);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public theme? = this._options.theme;
|
||||||
|
|
||||||
// All view processes. This is not used anymore to dispose all processes on deactivation (this is
|
// All view processes. This is not used anymore to dispose all processes on deactivation (this is
|
||||||
// now achieved using `this._mainProcess.autoDispose(...)`) but rather to be able to dispatch
|
// now achieved using `this._mainProcess.autoDispose(...)`) but rather to be able to dispatch
|
||||||
// events to all processes (such as doc actions which will need soon).
|
// events to all processes (such as doc actions which will need soon).
|
||||||
@ -80,17 +84,30 @@ export class SafeBrowser extends BaseComponent {
|
|||||||
private _mainProcess: WorkerProcess|undefined;
|
private _mainProcess: WorkerProcess|undefined;
|
||||||
private _viewCount: number = 0;
|
private _viewCount: number = 0;
|
||||||
|
|
||||||
constructor(
|
private _plugin = this._options.pluginInstance;
|
||||||
private _plugin: PluginInstance,
|
private _clientScope = this._options.clientScope;
|
||||||
private _clientScope: ClientScope,
|
private _untrustedContentOrigin = this._options.untrustedContentOrigin;
|
||||||
private _untrustedContentOrigin: string,
|
private _mainPath = this._options.mainPath ?? '';
|
||||||
private _mainPath: string = "",
|
private _baseLogger = this._options.baseLogger ?? console;
|
||||||
private _baseLogger: BaseLogger = console,
|
|
||||||
rpcLogger = createRpcLogger(_baseLogger, `PLUGIN ${_plugin.definition.id} SafeBrowser:`),
|
constructor(private _options: {
|
||||||
) {
|
pluginInstance: PluginInstance,
|
||||||
super(_plugin.definition.manifest, rpcLogger);
|
clientScope: ClientScope,
|
||||||
this._pluginId = _plugin.definition.id;
|
untrustedContentOrigin: string,
|
||||||
this._pluginRpc = _plugin.rpc;
|
mainPath?: string,
|
||||||
|
baseLogger?: BaseLogger,
|
||||||
|
rpcLogger?: IRpcLogger,
|
||||||
|
theme?: Computed<Theme>,
|
||||||
|
}) {
|
||||||
|
super(
|
||||||
|
_options.pluginInstance.definition.manifest,
|
||||||
|
_options.rpcLogger ?? createRpcLogger(
|
||||||
|
_options.baseLogger ?? console,
|
||||||
|
`PLUGIN ${_options.pluginInstance.definition.id} SafeBrowser:`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this._pluginId = this._plugin.definition.id;
|
||||||
|
this._pluginRpc = this._plugin.rpc;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -274,6 +291,9 @@ class WorkerProcess extends ClientProcess {
|
|||||||
|
|
||||||
export class ViewProcess extends ClientProcess {
|
export class ViewProcess extends ClientProcess {
|
||||||
public element: HTMLElement;
|
public element: HTMLElement;
|
||||||
|
|
||||||
|
// Set once all of the plugin's onOptions handlers have been called.
|
||||||
|
protected _optionsInitialized: Observable<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -282,10 +302,23 @@ export class ViewProcess extends ClientProcess {
|
|||||||
class IframeProcess extends ViewProcess {
|
class IframeProcess extends ViewProcess {
|
||||||
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
|
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
|
||||||
super.create(safeBrowser, rpc, src);
|
super.create(safeBrowser, rpc, src);
|
||||||
const iframe = this.element = this.autoDispose(dom(`iframe.safe_browser_process.clipboard_focus`,
|
this._optionsInitialized = Observable.create(this, false);
|
||||||
{ src }));
|
const iframe = this.element = this.autoDispose(
|
||||||
const listener = (event: MessageEvent) => {
|
grainjsDom(`iframe.safe_browser_process.clipboard_focus`,
|
||||||
|
{src},
|
||||||
|
grainjsDom.style('visibility', use => use(this._optionsInitialized) ? 'visible' : 'hidden'),
|
||||||
|
) as HTMLIFrameElement
|
||||||
|
);
|
||||||
|
const listener = async (event: MessageEvent) => {
|
||||||
if (event.source === iframe.contentWindow) {
|
if (event.source === iframe.contentWindow) {
|
||||||
|
if (event.data.mtype === MsgType.Ready) {
|
||||||
|
await this._sendSettings({theme: safeBrowser.theme?.get()}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.data?.settings?.status === 'initialized') {
|
||||||
|
this._optionsInitialized.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
this.rpc.receiveMessage(event.data);
|
this.rpc.receiveMessage(event.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -294,8 +327,21 @@ class IframeProcess extends ViewProcess {
|
|||||||
G.window.removeEventListener('message', listener);
|
G.window.removeEventListener('message', listener);
|
||||||
});
|
});
|
||||||
this.rpc.setSendMessage(msg => iframe.contentWindow!.postMessage(msg, '*'));
|
this.rpc.setSendMessage(msg => iframe.contentWindow!.postMessage(msg, '*'));
|
||||||
|
|
||||||
|
if (safeBrowser.theme) {
|
||||||
|
this.autoDispose(
|
||||||
|
safeBrowser.theme.addListener(async (newTheme, oldTheme) => {
|
||||||
|
if (isEqual(newTheme, oldTheme)) { return; }
|
||||||
|
|
||||||
|
await this._sendSettings({theme: safeBrowser.theme?.get()});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _sendSettings(settings: {theme?: Theme}, fromReady = false) {
|
||||||
|
await this.rpc.postMessage({settings, fromReady});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -266,6 +266,13 @@ export interface CustomViewSectionDef {
|
|||||||
* The section id.
|
* The section id.
|
||||||
*/
|
*/
|
||||||
sectionId: modelUtil.KoSaveableObservable<string>;
|
sectionId: modelUtil.KoSaveableObservable<string>;
|
||||||
|
/**
|
||||||
|
* If set, render the widget after `grist.ready()`.
|
||||||
|
*
|
||||||
|
* Currently, this is only used to defer rendering a widget until it has had
|
||||||
|
* a chance to apply the Grist theme.
|
||||||
|
*/
|
||||||
|
renderAfterReady: modelUtil.KoSaveableObservable<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Information about filters for a field or hidden column. */
|
/** Information about filters for a field or hidden column. */
|
||||||
@ -317,7 +324,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
widgetDef: null,
|
widgetDef: null,
|
||||||
access: '',
|
access: '',
|
||||||
pluginId: '',
|
pluginId: '',
|
||||||
sectionId: ''
|
sectionId: '',
|
||||||
|
renderAfterReady: false,
|
||||||
};
|
};
|
||||||
const customDefObj = modelUtil.jsonObservable(this.optionsObj.prop('customView'),
|
const customDefObj = modelUtil.jsonObservable(this.optionsObj.prop('customView'),
|
||||||
(obj: any) => defaults(obj || {}, customViewDefaults));
|
(obj: any) => defaults(obj || {}, customViewDefaults));
|
||||||
@ -330,7 +338,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
columnsMapping: customDefObj.prop('columnsMapping'),
|
columnsMapping: customDefObj.prop('columnsMapping'),
|
||||||
access: customDefObj.prop('access'),
|
access: customDefObj.prop('access'),
|
||||||
pluginId: customDefObj.prop('pluginId'),
|
pluginId: customDefObj.prop('pluginId'),
|
||||||
sectionId: customDefObj.prop('sectionId')
|
sectionId: customDefObj.prop('sectionId'),
|
||||||
|
renderAfterReady: customDefObj.prop('renderAfterReady'),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.selectedFields = ko.observable<any>([]);
|
this.selectedFields = ko.observable<any>([]);
|
||||||
|
@ -327,6 +327,8 @@ export class CustomSectionConfig extends Disposable {
|
|||||||
if (value === CUSTOM_ID) {
|
if (value === CUSTOM_ID) {
|
||||||
// Select Custom URL
|
// Select Custom URL
|
||||||
bundleChanges(() => {
|
bundleChanges(() => {
|
||||||
|
// Reset whether widget should render after `grist.ready()`.
|
||||||
|
_section.customDef.renderAfterReady(false);
|
||||||
// Clear url.
|
// Clear url.
|
||||||
_section.customDef.url(null);
|
_section.customDef.url(null);
|
||||||
// Clear widget definition.
|
// Clear widget definition.
|
||||||
@ -355,6 +357,8 @@ export class CustomSectionConfig extends Disposable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bundleChanges(() => {
|
bundleChanges(() => {
|
||||||
|
// Reset whether widget should render after `grist.ready()`.
|
||||||
|
_section.customDef.renderAfterReady(false);
|
||||||
// Clear access level
|
// Clear access level
|
||||||
_section.customDef.access(AccessLevel.none);
|
_section.customDef.access(AccessLevel.none);
|
||||||
// When widget wants some access, set desired access level.
|
// When widget wants some access, set desired access level.
|
||||||
@ -378,7 +382,13 @@ export class CustomSectionConfig extends Disposable {
|
|||||||
// Url for the widget, taken either from widget definition, or provided by hand for Custom URL.
|
// 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.
|
// For custom widget, we will store url also in section definition.
|
||||||
this._url = Computed.create(this, use => use(_section.customDef.url) || '');
|
this._url = Computed.create(this, use => use(_section.customDef.url) || '');
|
||||||
this._url.onWrite(newUrl => _section.customDef.url.setAndSave(newUrl));
|
this._url.onWrite(async newUrl => {
|
||||||
|
bundleChanges(() => {
|
||||||
|
_section.customDef.renderAfterReady(false);
|
||||||
|
_section.customDef.url(newUrl);
|
||||||
|
});
|
||||||
|
await _section.saveCustomDef();
|
||||||
|
});
|
||||||
|
|
||||||
// Compute current access level.
|
// Compute current access level.
|
||||||
this._currentAccess = Computed.create(
|
this._currentAccess = Computed.create(
|
||||||
|
@ -18,6 +18,13 @@ export interface ICustomWidget {
|
|||||||
* Optional desired access level.
|
* Optional desired access level.
|
||||||
*/
|
*/
|
||||||
accessLevel?: AccessLevel;
|
accessLevel?: AccessLevel;
|
||||||
|
/**
|
||||||
|
* If set, Grist will render the widget after `grist.ready()`.
|
||||||
|
*
|
||||||
|
* Currently, this is only used to defer rendering a widget until it has had
|
||||||
|
* a chance to apply the Grist theme.
|
||||||
|
*/
|
||||||
|
renderAfterReady?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -24,6 +24,7 @@ export const InteractionOptionsRequest = t.iface([], {
|
|||||||
|
|
||||||
export const InteractionOptions = t.iface([], {
|
export const InteractionOptions = t.iface([], {
|
||||||
"accessLevel": "string",
|
"accessLevel": "string",
|
||||||
|
"theme": "any",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const WidgetColumnMap = t.iface([], {
|
export const WidgetColumnMap = t.iface([], {
|
||||||
|
@ -63,7 +63,17 @@ export interface InteractionOptions{
|
|||||||
/**
|
/**
|
||||||
* Granted access level.
|
* Granted access level.
|
||||||
*/
|
*/
|
||||||
accessLevel: string,
|
accessLevel: string,
|
||||||
|
/**
|
||||||
|
* Information about the current Grist theme.
|
||||||
|
*
|
||||||
|
* Includes the theme appearance ("light" or "dark"), and a mapping of UI elements to
|
||||||
|
* CSS color values. The CSS values are also accessible within a widget via CSS variables
|
||||||
|
* prefixed with "--grist-theme-" (e.g. `var(--grist-theme-text)`).
|
||||||
|
*
|
||||||
|
* NOTE: the variables aren't yet finalized and may change in the future.
|
||||||
|
*/
|
||||||
|
theme: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -43,6 +43,7 @@ export * from './WidgetAPI';
|
|||||||
export * from './CustomSectionAPI';
|
export * from './CustomSectionAPI';
|
||||||
|
|
||||||
import {IRpcLogger, Rpc} from 'grain-rpc';
|
import {IRpcLogger, Rpc} from 'grain-rpc';
|
||||||
|
import isEqual from 'lodash/isEqual';
|
||||||
|
|
||||||
export const rpc: Rpc = new Rpc({logger: createRpcLogger()});
|
export const rpc: Rpc = new Rpc({logger: createRpcLogger()});
|
||||||
|
|
||||||
@ -376,6 +377,13 @@ export function onRecords(callback: (data: RowRecord[], mappings: WidgetColumnMa
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep track of the completion status of all initial onOptions calls made prior to sending
|
||||||
|
* the ready message. The `ready` function will wait for this list of calls to settle after
|
||||||
|
* it receives the initial options message from Grist.
|
||||||
|
*
|
||||||
|
* Note that this includes all internal calls to `onOptions`, such as the one made by
|
||||||
|
* `syncThemeWithGrist`. */
|
||||||
|
const _pendingInitializeOptionsCalls: Promise<unknown>[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For custom widgets, add a handler that will be called whenever the
|
* For custom widgets, add a handler that will be called whenever the
|
||||||
@ -385,9 +393,16 @@ export function onRecords(callback: (data: RowRecord[], mappings: WidgetColumnMa
|
|||||||
* the document that contains it.
|
* the document that contains it.
|
||||||
*/
|
*/
|
||||||
export function onOptions(callback: (options: any, settings: InteractionOptions) => unknown) {
|
export function onOptions(callback: (options: any, settings: InteractionOptions) => unknown) {
|
||||||
|
let finishInitialization: () => void;
|
||||||
|
if (!_readyCalled) {
|
||||||
|
const promise = new Promise<void>(resolve => { finishInitialization = resolve; });
|
||||||
|
_pendingInitializeOptionsCalls.push(promise);
|
||||||
|
}
|
||||||
|
|
||||||
on('message', async function(msg) {
|
on('message', async function(msg) {
|
||||||
if (msg.settings) {
|
if (msg.settings) {
|
||||||
callback(msg.options || null, msg.settings);
|
await callback(msg.options || null, msg.settings);
|
||||||
|
finishInitialization?.();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -444,6 +459,24 @@ export function ready(settings?: ReadyPayload): void {
|
|||||||
rpc.registerFunc('editOptions', settings.onEditOptions);
|
rpc.registerFunc('editOptions', settings.onEditOptions);
|
||||||
}
|
}
|
||||||
on('message', async function(msg) {
|
on('message', async function(msg) {
|
||||||
|
if (msg.settings && msg.fromReady) {
|
||||||
|
/* Grist sends an options message immediately after receiving a ready message, containing
|
||||||
|
* some initial settings (such as the Grist theme). Grist may decide to wait until all
|
||||||
|
* initial onOptions calls have completed before making the custom widget's iframe visible
|
||||||
|
* to the client. This is the case when a widget's manifest explicitly declares a widget
|
||||||
|
* must be rendered after the ready, or in subsequent renders of a custom widget that
|
||||||
|
* previously sent Grist a ready message. The reason for this behavior is to make the
|
||||||
|
* experience of embedding iframes within a Grist document feel more seamless and polished.
|
||||||
|
*
|
||||||
|
* Here, we check for that initial options message, waiting for all onOptions calls to
|
||||||
|
* settle before notifying Grist that all settings have been initialized. */
|
||||||
|
await Promise.all(_pendingInitializeOptionsCalls);
|
||||||
|
|
||||||
|
void (async function() {
|
||||||
|
await rpc.postMessage({settings: {status: 'initialized'}});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.tableId && msg.tableId !== _tableId) {
|
if (msg.tableId && msg.tableId !== _tableId) {
|
||||||
if (!_tableId) { _setInitialized(); }
|
if (!_tableId) { _setInitialized(); }
|
||||||
_tableId = msg.tableId;
|
_tableId = msg.tableId;
|
||||||
@ -521,3 +554,55 @@ function createRpcLogger(): IRpcLogger {
|
|||||||
warn(msg: string) { console.warn("%s %s", prefix, msg); },
|
warn(msg: string) { console.warn("%s %s", prefix, msg); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _theme: any;
|
||||||
|
|
||||||
|
function syncThemeWithGrist() {
|
||||||
|
onOptions((_options, settings) => {
|
||||||
|
if (settings.theme && !isEqual(settings.theme, _theme)) {
|
||||||
|
_theme = settings.theme;
|
||||||
|
attachCssThemeVars(_theme);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachCssThemeVars({appearance, colors}: any) {
|
||||||
|
// Prepare the custom properties needed for applying the theme.
|
||||||
|
const properties = Object.entries(colors)
|
||||||
|
.map(([name, value]) => `--grist-theme-${name}: ${value};`);
|
||||||
|
|
||||||
|
// Include properties for styling the scrollbar.
|
||||||
|
properties.push(...getCssScrollbarProperties(appearance));
|
||||||
|
|
||||||
|
// Apply the properties to the theme style element.
|
||||||
|
getOrCreateStyleElement('grist-theme').textContent = `:root {
|
||||||
|
${properties.join('\n')}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Make the browser aware of the color scheme.
|
||||||
|
document.documentElement.style.setProperty(`color-scheme`, appearance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCssScrollbarProperties(appearance: 'light' | 'dark') {
|
||||||
|
return [
|
||||||
|
'--scroll-bar-fg: ' +
|
||||||
|
(appearance === 'dark' ? '#6B6B6B;' : '#A8A8A8;'),
|
||||||
|
'--scroll-bar-hover-fg: ' +
|
||||||
|
(appearance === 'dark' ? '#7B7B7B;' : '#8F8F8F;'),
|
||||||
|
'--scroll-bar-active-fg: ' +
|
||||||
|
(appearance === 'dark' ? '#8B8B8B;' : '#7C7C7C;'),
|
||||||
|
'--scroll-bar-bg: ' +
|
||||||
|
(appearance === 'dark' ? '#2B2B2B;' : '#F0F0F0;'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateStyleElement(id: string) {
|
||||||
|
let style = document.head.querySelector(`#${id}`);
|
||||||
|
if (style) { return style; }
|
||||||
|
style = document.createElement('style');
|
||||||
|
style.setAttribute('id', id);
|
||||||
|
document.head.append(style);
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncThemeWithGrist();
|
||||||
|
@ -4,22 +4,46 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf8">
|
<meta charset="utf8">
|
||||||
<title>Custom widget</title>
|
<title>Custom widget</title>
|
||||||
|
<script src="/grist-plugin-api.js"></script>
|
||||||
|
<script>
|
||||||
|
grist.ready();
|
||||||
|
</script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #262633;
|
color: var(--grist-theme-text, #262633);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI,
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI,
|
||||||
Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
|
Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-light {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--grist-theme-text-light, #929299);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--grist-theme-link, #16B378);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover, a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div style="padding: 20px">
|
<div style="padding: 20px">
|
||||||
<div style="font-size: 20px; font-weight: 600;">
|
<div>
|
||||||
<span style="font-size: 20px; font-weight: 600;">Custom widget</span>
|
<span class="title">Custom widget</span>
|
||||||
<span style="font-size: 16px; font-weight: 400; color:#a7a7a7">(not configured)</span>
|
<span class="title-light">(not configured)</span>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
The Custom widget allows a user to insert almost anything in their document.
|
The Custom widget allows a user to insert almost anything in their document.
|
||||||
|
@ -180,7 +180,13 @@ describe('SafeBrowser', function() {
|
|||||||
};
|
};
|
||||||
function createSafeBrowser(mainPath: string): {safeBrowser: SafeBrowser, pluginRpc: Rpc} {
|
function createSafeBrowser(mainPath: string): {safeBrowser: SafeBrowser, pluginRpc: Rpc} {
|
||||||
const pluginInstance = new PluginInstance(localPlugin, {});
|
const pluginInstance = new PluginInstance(localPlugin, {});
|
||||||
const safeBrowser = new SafeBrowser(pluginInstance, clientScope, '', mainPath, {});
|
const safeBrowser = new SafeBrowser({
|
||||||
|
pluginInstance,
|
||||||
|
clientScope,
|
||||||
|
untrustedContentOrigin: '',
|
||||||
|
mainPath,
|
||||||
|
baseLogger: {},
|
||||||
|
});
|
||||||
cleanup.push(() => safeBrowser.deactivate());
|
cleanup.push(() => safeBrowser.deactivate());
|
||||||
pluginInstance.rpc.registerForwarder(mainPath, safeBrowser);
|
pluginInstance.rpc.registerForwarder(mainPath, safeBrowser);
|
||||||
return {safeBrowser, pluginRpc: pluginInstance.rpc};
|
return {safeBrowser, pluginRpc: pluginInstance.rpc};
|
||||||
|
8
test/fixtures/sites/deferred-ready/index.html
vendored
Normal file
8
test/fixtures/sites/deferred-ready/index.html
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script src="/grist-plugin-api.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button onclick="grist.ready()">Ready</button>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -42,19 +42,15 @@ describe('CustomView', function() {
|
|||||||
// This tests if test id works. Feels counterintuitive to "test the test" but grist-widget repository test suite
|
// This tests if test id works. Feels counterintuitive to "test the test" but grist-widget repository test suite
|
||||||
// depends on this.
|
// depends on this.
|
||||||
it('informs about ready called', async () => {
|
it('informs about ready called', async () => {
|
||||||
// Add a custom inline widget to a new doc.
|
// Add a custom widget to a new doc.
|
||||||
const session = await gu.session().teamSite.login();
|
const session = await gu.session().teamSite.login();
|
||||||
await session.tempNewDoc(cleanup);
|
await session.tempNewDoc(cleanup);
|
||||||
await gu.addNewSection('Custom', 'Table1');
|
await gu.addNewSection('Custom', 'Table1');
|
||||||
|
|
||||||
// Create an inline widget that will call ready message.
|
// Point to a widget that doesn't immediately call ready.
|
||||||
await inFrame(async () => {
|
await gu.toggleSidePanel('right', 'open');
|
||||||
const customWidget = `
|
await driver.find('.test-config-widget-url').click();
|
||||||
<script src="/grist-plugin-api.js"></script>
|
await gu.sendKeys(`${serving.url}/deferred-ready`, Key.ENTER);
|
||||||
<button onclick="grist.ready()">Ready</button>
|
|
||||||
`;
|
|
||||||
await driver.executeScript("document.write(`" + customWidget + "`);");
|
|
||||||
});
|
|
||||||
|
|
||||||
// We should have a single iframe.
|
// We should have a single iframe.
|
||||||
assert.equal(await driver.findAll('iframe').then(f => f.length), 1);
|
assert.equal(await driver.findAll('iframe').then(f => f.length), 1);
|
||||||
|
@ -19,6 +19,16 @@ const CUSTOM_URL = 'Custom URL';
|
|||||||
// Create some widgets:
|
// Create some widgets:
|
||||||
const widget1: ICustomWidget = {widgetId: '1', name: 'W1', url: widgetEndpoint + '?name=W1'};
|
const widget1: ICustomWidget = {widgetId: '1', name: 'W1', url: widgetEndpoint + '?name=W1'};
|
||||||
const widget2: ICustomWidget = {widgetId: '2', name: 'W2', url: widgetEndpoint + '?name=W2'};
|
const widget2: ICustomWidget = {widgetId: '2', name: 'W2', url: widgetEndpoint + '?name=W2'};
|
||||||
|
const widgetWithTheme: ICustomWidget = {
|
||||||
|
widgetId: '3',
|
||||||
|
name: 'WithTheme',
|
||||||
|
url: widgetEndpoint + '?name=WithTheme',
|
||||||
|
};
|
||||||
|
const widgetNoPluginApi: ICustomWidget = {
|
||||||
|
widgetId: '4',
|
||||||
|
name: 'NoPluginApi',
|
||||||
|
url: widgetEndpoint + '?name=NoPluginApi',
|
||||||
|
};
|
||||||
const fromAccess = (level: AccessLevel) =>
|
const fromAccess = (level: AccessLevel) =>
|
||||||
({widgetId: level, name: level, url: widgetEndpoint, accessLevel: level}) as ICustomWidget;
|
({widgetId: level, name: level, url: widgetEndpoint, accessLevel: level}) as ICustomWidget;
|
||||||
const widgetNone = fromAccess(AccessLevel.none);
|
const widgetNone = fromAccess(AccessLevel.none);
|
||||||
@ -54,8 +64,13 @@ describe('CustomWidgets', function () {
|
|||||||
app.get(widgetEndpoint, (req, res) =>
|
app.get(widgetEndpoint, (req, res) =>
|
||||||
res
|
res
|
||||||
.header('Content-Type', 'text/html')
|
.header('Content-Type', 'text/html')
|
||||||
.send('<html><head><script src="/grist-plugin-api.js"></script></head><body>\n' +
|
.send('<html><head>' +
|
||||||
|
(req.query.name === 'NoPluginApi' ? '' : '<script src="/grist-plugin-api.js"></script>') +
|
||||||
|
(req.query.name === 'WithTheme' ? '<script>grist.ready();</script>' : '') +
|
||||||
|
'</head><body>\n' +
|
||||||
|
(req.query.name === 'WithTheme' ? '<span style="color: var(--grist-theme-text);">' : '') +
|
||||||
(req.query.name || req.query.access) + // send back widget name from query string or access level
|
(req.query.name || req.query.access) + // send back widget name from query string or access level
|
||||||
|
(req.query.name === 'WithTheme' ? '</span>' : '') +
|
||||||
'</body></html>\n')
|
'</body></html>\n')
|
||||||
.end()
|
.end()
|
||||||
);
|
);
|
||||||
@ -146,7 +161,7 @@ describe('CustomWidgets', function () {
|
|||||||
const setUrl = async (url: string) => {
|
const setUrl = async (url: string) => {
|
||||||
await driver.find('.test-config-widget-url').click();
|
await driver.find('.test-config-widget-url').click();
|
||||||
// First clear textbox.
|
// First clear textbox.
|
||||||
await gu.clearInput();
|
await gu.sendKeys(await gu.selectAllKey(), Key.DELETE);
|
||||||
if (url) {
|
if (url) {
|
||||||
await gu.sendKeys(`${widgetServerUrl}${url}`, Key.ENTER);
|
await gu.sendKeys(`${widgetServerUrl}${url}`, Key.ENTER);
|
||||||
} else {
|
} else {
|
||||||
@ -242,6 +257,64 @@ describe('CustomWidgets', function () {
|
|||||||
await gu.undo(7);
|
await gu.undo(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should support theme variables', async () => {
|
||||||
|
widgets = [widgetWithTheme];
|
||||||
|
await useManifest(manifestEndpoint);
|
||||||
|
await recreatePanel();
|
||||||
|
await toggle();
|
||||||
|
await select(widgetWithTheme.name);
|
||||||
|
assert.equal(await current(), widgetWithTheme.name);
|
||||||
|
assert.equal(await content(), widgetWithTheme.name);
|
||||||
|
|
||||||
|
const getWidgetColor = async () => {
|
||||||
|
const iframe = driver.find('iframe');
|
||||||
|
await driver.switchTo().frame(iframe);
|
||||||
|
const color = await driver.find('span').getCssValue('color');
|
||||||
|
await driver.switchTo().defaultContent();
|
||||||
|
return color;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check that the widget is using the text color from the GristLight theme.
|
||||||
|
assert.equal(await getWidgetColor(), 'rgba(38, 38, 51, 1)');
|
||||||
|
|
||||||
|
// Switch the theme to GristDark.
|
||||||
|
await gu.setGristTheme({appearance: 'dark'});
|
||||||
|
await driver.navigate().back();
|
||||||
|
await gu.waitForDocToLoad();
|
||||||
|
|
||||||
|
// Check that the span is using the text color from the GristDark theme.
|
||||||
|
assert.equal(await getWidgetColor(), 'rgba(239, 239, 239, 1)');
|
||||||
|
|
||||||
|
// Switch back to GristLight.
|
||||||
|
await gu.setGristTheme({appearance: 'light'});
|
||||||
|
await driver.navigate().back();
|
||||||
|
await gu.waitForDocToLoad();
|
||||||
|
|
||||||
|
// Check that the widget is back to using the GristLight text color.
|
||||||
|
assert.equal(await getWidgetColor(), 'rgba(38, 38, 51, 1)');
|
||||||
|
|
||||||
|
// Re-enable widget repository.
|
||||||
|
await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support widgets that don't use the plugin api", async () => {
|
||||||
|
widgets = [widgetNoPluginApi];
|
||||||
|
await useManifest(manifestEndpoint);
|
||||||
|
await recreatePanel();
|
||||||
|
await toggle();
|
||||||
|
await select(widgetNoPluginApi.name);
|
||||||
|
assert.equal(await current(), widgetNoPluginApi.name);
|
||||||
|
|
||||||
|
// Check that the widget loaded and its iframe is visible.
|
||||||
|
assert.equal(await content(), widgetNoPluginApi.name);
|
||||||
|
assert.isTrue(await driver.find('iframe').isDisplayed());
|
||||||
|
|
||||||
|
// Revert to original configuration.
|
||||||
|
widgets = [widget1, widget2];
|
||||||
|
await useManifest(manifestEndpoint);
|
||||||
|
await recreatePanel();
|
||||||
|
});
|
||||||
|
|
||||||
it('should show error message for invalid widget url list', async () => {
|
it('should show error message for invalid widget url list', async () => {
|
||||||
const testError = async (url: string, error: string) => {
|
const testError = async (url: string, error: string) => {
|
||||||
// Switch section to rebuild the creator panel.
|
// Switch section to rebuild the creator panel.
|
||||||
@ -283,10 +356,6 @@ describe('CustomWidgets', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should switch access level to none on new widget', async () => {
|
it('should switch access level to none on new widget', async () => {
|
||||||
widgets = [widget1, widget2];
|
|
||||||
await useManifest(manifestEndpoint);
|
|
||||||
await recreatePanel();
|
|
||||||
|
|
||||||
await toggle();
|
await toggle();
|
||||||
await select(widget1.name);
|
await select(widget1.name);
|
||||||
assert.equal(await access(), AccessLevel.none);
|
assert.equal(await access(), AccessLevel.none);
|
||||||
|
@ -3131,6 +3131,20 @@ export async function downloadSectionCsvGridCells(
|
|||||||
return ([] as string[]).concat(...csvRows);
|
return ([] as string[]).concat(...csvRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setGristTheme(options: {
|
||||||
|
appearance: 'light' | 'dark',
|
||||||
|
skipOpenSettingsPage?: boolean,
|
||||||
|
}) {
|
||||||
|
const {appearance, skipOpenSettingsPage} = options;
|
||||||
|
if (!skipOpenSettingsPage) {
|
||||||
|
await openProfileSettingsPage();
|
||||||
|
}
|
||||||
|
await driver.find('.test-theme-config-appearance .test-select-open').click();
|
||||||
|
await driver.findContent('.test-select-menu li', appearance === 'light' ? 'Light' : 'Dark')
|
||||||
|
.click();
|
||||||
|
await waitForServer();
|
||||||
|
}
|
||||||
|
|
||||||
} // end of namespace gristUtils
|
} // end of namespace gristUtils
|
||||||
|
|
||||||
stackWrapOwnMethods(gristUtils);
|
stackWrapOwnMethods(gristUtils);
|
||||||
|
Loading…
Reference in New Issue
Block a user