mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +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:
@@ -153,8 +153,14 @@ export class CustomView extends Disposable {
|
||||
}
|
||||
|
||||
private _buildDom() {
|
||||
const {mode, url, access} = this.customDef;
|
||||
const {mode, url, access, renderAfterReady} = this.customDef;
|
||||
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.
|
||||
const showPluginNotification = ko.pureComputed(() => showPlugin() && !this._foundPlugin());
|
||||
@@ -170,7 +176,14 @@ 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 || 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 ',
|
||||
dom('strong', kd.text(this.customDef.pluginId)), ' was not found',
|
||||
dom.testId('customView_notification_plugin')
|
||||
@@ -193,11 +206,22 @@ export class CustomView extends Disposable {
|
||||
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, {
|
||||
url: baseUrl || this.getEmptyWidgetPage(),
|
||||
access,
|
||||
readonly: this.gristDoc.isReadonly.get(),
|
||||
showAfterReady,
|
||||
onSettingsInitialized: async () => {
|
||||
if (!this.customDef.renderAfterReady.peek()) {
|
||||
await this.customDef.renderAfterReady.setAndSave(true);
|
||||
}
|
||||
},
|
||||
configure: (frame) => {
|
||||
this._frame = frame;
|
||||
// Need to cast myself to a BaseView
|
||||
@@ -223,7 +247,10 @@ export class CustomView extends Disposable {
|
||||
new WidgetAPIImpl(this.viewSection),
|
||||
new MinimumLevel(AccessLevel.none)); // none access is enough
|
||||
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
|
||||
},
|
||||
onElem: (iframe) => onFrameFocus(iframe, () => {
|
||||
|
||||
@@ -224,8 +224,13 @@ export class GristDoc extends DisposableWithEvents {
|
||||
this.docData = new DocData(this.docComm, openDocResponse.doc);
|
||||
this.docModel = new DocModel(this.docData, this.docPageModel);
|
||||
this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm);
|
||||
this.docPluginManager = new DocPluginManager(plugins,
|
||||
app.topAppModel.getUntrustedContentOrigin(), this.docComm, app.clientScope);
|
||||
this.docPluginManager = new DocPluginManager({
|
||||
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.
|
||||
this.docInfo = this.docModel.docInfoRow;
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 {Theme} from 'app/common/ThemePrefs';
|
||||
import {AccessTokenOptions, CursorPos, CustomSectionAPI, GristDocAPI, GristView,
|
||||
InteractionOptionsRequest, WidgetAPI, WidgetColumnMap} from 'app/plugin/grist-plugin-api';
|
||||
import {MsgType, Rpc} from 'grain-rpc';
|
||||
@@ -48,6 +49,20 @@ export interface WidgetFrameOptions {
|
||||
* If document is in readonly mode.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -68,6 +83,8 @@ export class WidgetFrame extends DisposableWithEvents {
|
||||
private _iframe: HTMLIFrameElement | null;
|
||||
// If widget called ready() method, this will be set to true.
|
||||
private _readyCalled = Observable.create(this, false);
|
||||
// Whether the iframe is visible.
|
||||
private _visible = Observable.create(this, !this._options.showAfterReady);
|
||||
|
||||
constructor(private _options: WidgetFrameOptions) {
|
||||
super();
|
||||
@@ -162,6 +179,7 @@ export class WidgetFrame extends DisposableWithEvents {
|
||||
const onElem = this._options.onElem ?? ((el: HTMLIFrameElement) => el);
|
||||
return onElem(
|
||||
(this._iframe = dom('iframe',
|
||||
dom.style('visibility', use => use(this._visible) ? 'visible' : 'hidden'),
|
||||
dom.cls('clipboard_focus'),
|
||||
dom.cls('custom_view'), {
|
||||
src: fullUrl,
|
||||
@@ -189,6 +207,10 @@ export class WidgetFrame extends DisposableWithEvents {
|
||||
this.trigger('ready', this);
|
||||
this._readyCalled.set(true);
|
||||
}
|
||||
if (event.data.data?.settings?.status === 'initialized') {
|
||||
this._visible.set(true);
|
||||
this._options.onSettingsInitialized();
|
||||
}
|
||||
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.
|
||||
*/
|
||||
export class ConfigNotifier extends BaseEventSource {
|
||||
private _accessLevel = this._options.access;
|
||||
private _theme = this._options.theme;
|
||||
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) {
|
||||
// Debounced call to let the view know linked cursor changed.
|
||||
private _debounced: (fromReady?: boolean) => void;
|
||||
constructor(private _section: ViewSectionRec, private _options: ConfigNotifierOptions) {
|
||||
super();
|
||||
this._currentConfig = Computed.create(this, use => {
|
||||
const options = use(this._section.activeCustomOptions);
|
||||
return options;
|
||||
});
|
||||
this._debounced = debounce(() => this._update(), 0);
|
||||
const subscribe = (obs: Observable<any>) => {
|
||||
this.autoDispose(
|
||||
obs.addListener((cur, prev) => {
|
||||
if (isEqual(prev, cur)) {
|
||||
return;
|
||||
}
|
||||
this._debounced();
|
||||
})
|
||||
);
|
||||
this._debounced = debounce((fromReady?: boolean) => this._update(fromReady), 0);
|
||||
const subscribe = (...observables: Observable<any>[]) => {
|
||||
for (const obs of observables) {
|
||||
this.autoDispose(
|
||||
obs.addListener((cur, prev) => {
|
||||
if (isEqual(prev, cur)) {
|
||||
return;
|
||||
}
|
||||
this._debounced();
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
subscribe(this._currentConfig);
|
||||
subscribe(this._currentConfig, this._theme);
|
||||
}
|
||||
|
||||
protected _ready() {
|
||||
// On ready, send initial configuration.
|
||||
this._debounced();
|
||||
this._debounced(true);
|
||||
}
|
||||
|
||||
private _update() {
|
||||
private _update(fromReady = false) {
|
||||
if (this.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
@@ -562,7 +594,9 @@ export class ConfigNotifier extends BaseEventSource {
|
||||
options: this._currentConfig.get(),
|
||||
settings: {
|
||||
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 {LocalPlugin} from 'app/common/plugin';
|
||||
import {createRpcLogger, PluginInstance} from 'app/common/PluginInstance';
|
||||
import {Theme} from 'app/common/ThemePrefs';
|
||||
import {Computed} from 'grainjs';
|
||||
import {Rpc} from 'grain-rpc';
|
||||
|
||||
/**
|
||||
@@ -13,15 +15,31 @@ export class DocPluginManager {
|
||||
|
||||
public pluginsList: PluginInstance[];
|
||||
|
||||
constructor(localPlugins: LocalPlugin[], private _untrustedContentOrigin: string, private _docComm: ActiveDocAPI,
|
||||
private _clientScope: ClientScope) {
|
||||
private _clientScope = this._options.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 = [];
|
||||
for (const plugin of localPlugins) {
|
||||
for (const plugin of this._localPlugins) {
|
||||
try {
|
||||
const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `PLUGIN ${plugin.id}:`));
|
||||
const components = plugin.manifest.components || {};
|
||||
const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser(pluginInstance,
|
||||
this._clientScope, this._untrustedContentOrigin, components.safeBrowser);
|
||||
const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser({
|
||||
pluginInstance,
|
||||
clientScope: this._clientScope,
|
||||
untrustedContentOrigin: this._untrustedContentOrigin,
|
||||
mainPath: components.safeBrowser,
|
||||
theme: this._theme,
|
||||
});
|
||||
if (components.safeBrowser) {
|
||||
pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ export class HomePluginManager {
|
||||
public pluginsList: PluginInstance[];
|
||||
|
||||
constructor(localPlugins: LocalPlugin[],
|
||||
_untrustedContentOrigin: string,
|
||||
_clientScope: ClientScope) {
|
||||
untrustedContentOrigin: string,
|
||||
clientScope: ClientScope) {
|
||||
this.pluginsList = [];
|
||||
for (const plugin of localPlugins) {
|
||||
try {
|
||||
@@ -30,8 +30,12 @@ export class HomePluginManager {
|
||||
continue;
|
||||
}
|
||||
const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `HOME PLUGIN ${plugin.id}:`));
|
||||
const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser(pluginInstance,
|
||||
_clientScope, _untrustedContentOrigin, components.safeBrowser);
|
||||
const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser({
|
||||
pluginInstance,
|
||||
clientScope,
|
||||
untrustedContentOrigin,
|
||||
mainPath: components.safeBrowser,
|
||||
});
|
||||
if (components.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 { BaseComponent, BaseLogger, createRpcLogger, PluginInstance, warnIfNotReady } from 'app/common/PluginInstance';
|
||||
import { tbind } from 'app/common/tbind';
|
||||
import { Theme } from 'app/common/ThemePrefs';
|
||||
import { getOriginUrl } from 'app/common/urlUtils';
|
||||
import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI';
|
||||
import { RenderOptions, RenderTarget } from 'app/plugin/RenderOptions';
|
||||
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 isEqual from 'lodash/isEqual';
|
||||
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
|
||||
// disposeImpl. ClientProcess will hold a reference to ProcessManager instead of SafeBrowser.
|
||||
export class SafeBrowser extends BaseComponent {
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
public theme? = this._options.theme;
|
||||
|
||||
// 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
|
||||
// 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 _viewCount: number = 0;
|
||||
|
||||
constructor(
|
||||
private _plugin: PluginInstance,
|
||||
private _clientScope: ClientScope,
|
||||
private _untrustedContentOrigin: string,
|
||||
private _mainPath: string = "",
|
||||
private _baseLogger: BaseLogger = console,
|
||||
rpcLogger = createRpcLogger(_baseLogger, `PLUGIN ${_plugin.definition.id} SafeBrowser:`),
|
||||
) {
|
||||
super(_plugin.definition.manifest, rpcLogger);
|
||||
this._pluginId = _plugin.definition.id;
|
||||
this._pluginRpc = _plugin.rpc;
|
||||
private _plugin = this._options.pluginInstance;
|
||||
private _clientScope = this._options.clientScope;
|
||||
private _untrustedContentOrigin = this._options.untrustedContentOrigin;
|
||||
private _mainPath = this._options.mainPath ?? '';
|
||||
private _baseLogger = this._options.baseLogger ?? console;
|
||||
|
||||
constructor(private _options: {
|
||||
pluginInstance: PluginInstance,
|
||||
clientScope: ClientScope,
|
||||
untrustedContentOrigin: string,
|
||||
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 {
|
||||
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 {
|
||||
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
|
||||
super.create(safeBrowser, rpc, src);
|
||||
const iframe = this.element = this.autoDispose(dom(`iframe.safe_browser_process.clipboard_focus`,
|
||||
{ src }));
|
||||
const listener = (event: MessageEvent) => {
|
||||
this._optionsInitialized = Observable.create(this, false);
|
||||
const iframe = this.element = this.autoDispose(
|
||||
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.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);
|
||||
}
|
||||
};
|
||||
@@ -294,8 +327,21 @@ class IframeProcess extends ViewProcess {
|
||||
G.window.removeEventListener('message', listener);
|
||||
});
|
||||
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.
|
||||
*/
|
||||
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. */
|
||||
@@ -317,7 +324,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
||||
widgetDef: null,
|
||||
access: '',
|
||||
pluginId: '',
|
||||
sectionId: ''
|
||||
sectionId: '',
|
||||
renderAfterReady: false,
|
||||
};
|
||||
const customDefObj = modelUtil.jsonObservable(this.optionsObj.prop('customView'),
|
||||
(obj: any) => defaults(obj || {}, customViewDefaults));
|
||||
@@ -330,7 +338,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
||||
columnsMapping: customDefObj.prop('columnsMapping'),
|
||||
access: customDefObj.prop('access'),
|
||||
pluginId: customDefObj.prop('pluginId'),
|
||||
sectionId: customDefObj.prop('sectionId')
|
||||
sectionId: customDefObj.prop('sectionId'),
|
||||
renderAfterReady: customDefObj.prop('renderAfterReady'),
|
||||
};
|
||||
|
||||
this.selectedFields = ko.observable<any>([]);
|
||||
|
||||
@@ -327,6 +327,8 @@ export class CustomSectionConfig extends Disposable {
|
||||
if (value === CUSTOM_ID) {
|
||||
// Select Custom URL
|
||||
bundleChanges(() => {
|
||||
// Reset whether widget should render after `grist.ready()`.
|
||||
_section.customDef.renderAfterReady(false);
|
||||
// Clear url.
|
||||
_section.customDef.url(null);
|
||||
// Clear widget definition.
|
||||
@@ -355,6 +357,8 @@ export class CustomSectionConfig extends Disposable {
|
||||
return;
|
||||
}
|
||||
bundleChanges(() => {
|
||||
// Reset whether widget should render after `grist.ready()`.
|
||||
_section.customDef.renderAfterReady(false);
|
||||
// Clear access level
|
||||
_section.customDef.access(AccessLevel.none);
|
||||
// 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.
|
||||
// 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.onWrite(async newUrl => {
|
||||
bundleChanges(() => {
|
||||
_section.customDef.renderAfterReady(false);
|
||||
_section.customDef.url(newUrl);
|
||||
});
|
||||
await _section.saveCustomDef();
|
||||
});
|
||||
|
||||
// Compute current access level.
|
||||
this._currentAccess = Computed.create(
|
||||
|
||||
Reference in New Issue
Block a user