(core) Exposing custom widgets on the UI

Summary:
Exposing custom widgets as a dropdown menu in custom section configuration panel.

Adding new environmental variable GRIST_WIDGET_LIST_URL that points to a
json file with an array of available widgets. When not present, custom widget menu is
hidden, exposing only Custom URL option.

Available widget list can be fetched from:
https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json

Test Plan: New tests, and updated old ones.

Reviewers: paulfitz, dsagal

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3127
This commit is contained in:
Jarosław Sadziński
2021-11-26 11:43:55 +01:00
parent be96db4689
commit 1425461cd8
16 changed files with 482 additions and 25 deletions

View File

@@ -52,7 +52,9 @@ export class CursorMonitor extends Disposable {
this.autoDispose(doc.cursorPosition.addListener(pos => {
// if current position is not restored yet, don't change it
if (!this._restored) { return; }
if (pos) { this._storePosition(pos); }
// store position only when we have valid rowId
// for some views (like CustomView) cursor position might not reflect actual row
if (pos && pos.rowId !== undefined) { this._storePosition(pos); }
}));
}

View File

@@ -771,20 +771,7 @@ ViewConfigTab.prototype._buildCustomTypeItems = function() {
}, {
// 2)
showObs: () => activeSection().customDef.mode() === "url",
buildDom: () => kd.scope(activeSection, ({customDef}) => dom('div',
kf.row(18, kf.text(customDef.url, {placeholder: "Full URL of webpage to show"}, dom.testId('ViewConfigTab_url'))),
kf.row(5, "Access", 13, dom(kf.select(customDef.access, ['none', 'read table', 'full']), dom.testId('ViewConfigTab_customView_access'))),
kf.helpRow('none: widget has no access to document.',
kd.style('text-align', 'left'),
kd.style('margin-top', '1.5rem')),
kf.helpRow('read table: widget can read the selected table.',
kd.style('text-align', 'left'),
kd.style('margin-top', '1.5rem')),
kf.helpRow('full: widget can read, modify, and copy the document.',
kd.style('text-align', 'left'),
kd.style('margin-top', '1.5rem'))
)),
// TODO: refactor this part, Custom Widget moved to separate file.
}, {
// 3)

View File

@@ -1,6 +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 * as ko from 'knockout';
import { CursorPos, } from 'app/client/components/Cursor';
import { KoArray, } from 'app/client/lib/koArray';
@@ -131,29 +132,36 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
// Apply `filter` to the field or column identified by `colRef`.
setFilter(colRef: number, filter: string): void;
// Saves custom definition (bundles change)
saveCustomDef(): Promise<void>;
}
export interface CustomViewSectionDef {
/**
* The mode.
*/
mode: ko.Observable<"url"|"plugin">;
mode: modelUtil.KoSaveableObservable<"url"|"plugin">;
/**
* The url.
*/
url: ko.Observable<string>;
url: modelUtil.KoSaveableObservable<string|null>;
/**
* Custom widget information.
*/
widgetDef: modelUtil.KoSaveableObservable<ICustomWidget|null>;
/**
* Access granted to url.
*/
access: ko.Observable<string>;
access: modelUtil.KoSaveableObservable<string>;
/**
* The plugin id.
*/
pluginId: ko.Observable<string>;
pluginId: modelUtil.KoSaveableObservable<string>;
/**
* The section id.
*/
sectionId: ko.Observable<string>;
sectionId: modelUtil.KoSaveableObservable<string>;
}
// Information about filters for a field or hidden column.
@@ -185,7 +193,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
const customViewDefaults = {
mode: 'url',
url: '',
url: null,
widgetDef: null,
access: '',
pluginId: '',
sectionId: ''
@@ -196,11 +205,16 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this.customDef = {
mode: customDefObj.prop('mode'),
url: customDefObj.prop('url'),
widgetDef: customDefObj.prop('widgetDef'),
access: customDefObj.prop('access'),
pluginId: customDefObj.prop('pluginId'),
sectionId: customDefObj.prop('sectionId')
};
this.saveCustomDef = () => {
return customDefObj.save();
};
this.themeDef = modelUtil.fieldWithDefault(this.theme, 'form');
this.chartTypeDef = modelUtil.fieldWithDefault(this.chartType, 'bar');
this.view = refRecord(docModel.views, this.parentId);

View File

@@ -123,7 +123,12 @@ export function reportError(err: Error|string): void {
} else {
// If we don't recognize it, consider it an application error (bug) that the user should be
// able to report.
_notifier.createAppError(err);
if (details?.userError) {
// If we have user friendly error, show it instead.
_notifier.createAppError(Error(details.userError));
} else {
_notifier.createAppError(err);
}
}
}
}

View File

@@ -0,0 +1,280 @@
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 {colors} from 'app/client/ui2018/cssVars';
import {cssLink} from 'app/client/ui2018/links';
import {IOptionFull, select} from 'app/client/ui2018/menus';
import {AccessLevel, ICustomWidget} 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';
// Custom URL widget id - used as mock id for selectbox.
const CUSTOM_ID = "custom";
const testId = makeTestId('test-config-widget-');
/**
* Custom Widget section.
* Allows to select custom widget from the list of available widgets
* (taken from /widgets endpoint), or enter a Custom URL.
* When Custom Widget has a desired access level (in accessLevel field),
* will prompt user to approve it. "None" access level is auto approved,
* so prompt won't be shown.
*
* When gristConfig.enableWidgetRepository is set to false, it will only
* allow to specify Custom URL.
*/
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 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>;
// Current access level (stored inside a section).
private _currentAccess: Computed<AccessLevel>;
constructor(section: ViewSectionRec, api: UserAPI) {
super();
// Test if we can offer widget list.
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
this._canSelect = gristConfig.enableWidgetRepository ?? true;
// Array of available widgets - will be updated asynchronously.
this._widgets = Observable.create(this, []);
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()!]);
}
// 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);
});
}
// 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())
);
// 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;
}
if (use(section.customDef.url) || use(wantsToBeCustom)) {
return CUSTOM_ID;
}
return null;
});
this._selected.onWrite(async (value) => {
if (value === CUSTOM_ID) {
// Select Custom URL
bundleChanges(() => {
// Clear url.
section.customDef.url(null);
// Clear widget definition.
section.customDef.widgetDef(null);
// Set intermediate state
wantsToBeCustom.set(true);
// Reset access level to none.
section.customDef.access(AccessLevel.none);
this._desiredAccess.set(AccessLevel.none);
});
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");
}
// If user selected the same one, do nothing.
if (section.customDef.widgetDef.peek()?.widgetId === value) {
return;
}
bundleChanges(() => {
// Clear access level
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);
// Update widget URL.
section.customDef.url(selectedWidget.url);
// Clear intermediate state.
wantsToBeCustom.set(false);
});
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));
// 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);
});
// Clear intermediate state when section changes.
this.autoDispose(section.id.subscribe(() => wantsToBeCustom.set(false)));
this.autoDispose(section.id.subscribe(() => this._reject()));
}
public buildDom() {
// UI observables holder.
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));
// If this is empty section or not.
const isSelected = Computed.create(holder, use => Boolean(use(this._selected)));
// 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 options = Computed.create(holder, use => [
{label: 'Custom URL', value: 'custom'},
...use(this._widgets).map(w => ({label: w.name, value: w.widgetId})),
]);
// Options for access level.
const levels: IOptionFull<string>[] = [
{label: 'No document access', value: AccessLevel.none},
{label: 'Read selected table', value: AccessLevel.read_table},
{label: 'Full document access', value: AccessLevel.full},
];
return dom(
'div',
dom.autoDispose(holder),
this._canSelect ?
cssRow(
select(this._selected, options, {
defaultLabel: 'Select Custom Widget',
menuCssClass: cssMenu.className
}),
testId('select')
) : null,
dom.maybe(isCustom, () => [
cssRow(
cssTextInput(
this._url,
async value => this._url.set(value),
dom.attr('placeholder', 'Enter Custom URL'),
testId('url')
)
),
]),
cssSection(
cssLink(
dom.attr('href', 'https://support.getgrist.com/widget-custom'),
dom.attr('target', '_blank'),
'Learn more about custom widgets'
)
),
dom.maybe((use) => use(isSelected) || !this._canSelect, () => [
cssLabel('ACCESS LEVEL'),
cssRow(select(this._selectedAccess, levels), testId('access')),
dom.maybe(prompt, () =>
kf.prompt(
{tabindex: '-1'},
cssColumns(
cssWarningWrapper(
icon('Lock'),
),
dom('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 _accept() {
this._selectedAccess.set(this._desiredAccess.get());
this._reject();
}
private _reject() {
this._desiredAccess.set(this._currentAccess.get());
}
}
const cssWarningWrapper = styled('div', `
padding-left: 8px;
padding-top: 6px;
--icon-color: ${colors.lightGreen}
`);
const cssColumns = styled('div', `
display: flex;
`);
const cssConfirmRow = styled('div', `
display: flex;
padding: 8px;
gap: 8px;
`);
const cssSection = styled('div', `
margin: 16px 16px 12px 16px;
`);
const cssMenu = styled('div', `
& > li:first-child {
border-bottom: 1px solid ${colors.mediumGrey};
}
`);

View File

@@ -40,6 +40,7 @@ import {bundleChanges, Computed, Disposable, dom, domComputed, DomContents,
DomElementArg, DomElementMethod, IDomComponent} from 'grainjs';
import {MultiHolder, Observable, styled, subscribe} from 'grainjs';
import * as ko from 'knockout';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
// Represents a top tab of the right side-pane.
const TopTab = StringUnion("pageWidget", "field");
@@ -337,7 +338,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('div', parts[1].buildDom())),
() => dom.create(CustomSectionConfig, activeSection, this._gristDoc.app.topAppModel.api)),
];
}
]),