mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
be96db4689
commit
1425461cd8
@ -52,7 +52,9 @@ export class CursorMonitor extends Disposable {
|
|||||||
this.autoDispose(doc.cursorPosition.addListener(pos => {
|
this.autoDispose(doc.cursorPosition.addListener(pos => {
|
||||||
// if current position is not restored yet, don't change it
|
// if current position is not restored yet, don't change it
|
||||||
if (!this._restored) { return; }
|
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); }
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -771,20 +771,7 @@ ViewConfigTab.prototype._buildCustomTypeItems = function() {
|
|||||||
}, {
|
}, {
|
||||||
|
|
||||||
// 2)
|
// 2)
|
||||||
showObs: () => activeSection().customDef.mode() === "url",
|
// TODO: refactor this part, Custom Widget moved to separate file.
|
||||||
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'))
|
|
||||||
)),
|
|
||||||
}, {
|
}, {
|
||||||
|
|
||||||
// 3)
|
// 3)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import * as BaseView from 'app/client/components/BaseView';
|
import * as BaseView from 'app/client/components/BaseView';
|
||||||
import { ColumnRec, FilterRec, TableRec, ViewFieldRec, ViewRec } from 'app/client/models/DocModel';
|
import { ColumnRec, FilterRec, TableRec, ViewFieldRec, ViewRec } from 'app/client/models/DocModel';
|
||||||
import * as modelUtil from 'app/client/models/modelUtil';
|
import * as modelUtil from 'app/client/models/modelUtil';
|
||||||
|
import {ICustomWidget} from 'app/common/CustomWidget';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
import { CursorPos, } from 'app/client/components/Cursor';
|
import { CursorPos, } from 'app/client/components/Cursor';
|
||||||
import { KoArray, } from 'app/client/lib/koArray';
|
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`.
|
// Apply `filter` to the field or column identified by `colRef`.
|
||||||
setFilter(colRef: number, filter: string): void;
|
setFilter(colRef: number, filter: string): void;
|
||||||
|
|
||||||
|
// Saves custom definition (bundles change)
|
||||||
|
saveCustomDef(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomViewSectionDef {
|
export interface CustomViewSectionDef {
|
||||||
/**
|
/**
|
||||||
* The mode.
|
* The mode.
|
||||||
*/
|
*/
|
||||||
mode: ko.Observable<"url"|"plugin">;
|
mode: modelUtil.KoSaveableObservable<"url"|"plugin">;
|
||||||
/**
|
/**
|
||||||
* The url.
|
* The url.
|
||||||
*/
|
*/
|
||||||
url: ko.Observable<string>;
|
url: modelUtil.KoSaveableObservable<string|null>;
|
||||||
|
/**
|
||||||
|
* Custom widget information.
|
||||||
|
*/
|
||||||
|
widgetDef: modelUtil.KoSaveableObservable<ICustomWidget|null>;
|
||||||
/**
|
/**
|
||||||
* Access granted to url.
|
* Access granted to url.
|
||||||
*/
|
*/
|
||||||
access: ko.Observable<string>;
|
access: modelUtil.KoSaveableObservable<string>;
|
||||||
/**
|
/**
|
||||||
* The plugin id.
|
* The plugin id.
|
||||||
*/
|
*/
|
||||||
pluginId: ko.Observable<string>;
|
pluginId: modelUtil.KoSaveableObservable<string>;
|
||||||
/**
|
/**
|
||||||
* The section id.
|
* The section id.
|
||||||
*/
|
*/
|
||||||
sectionId: ko.Observable<string>;
|
sectionId: modelUtil.KoSaveableObservable<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Information about filters for a field or hidden column.
|
// Information about filters for a field or hidden column.
|
||||||
@ -185,7 +193,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
|
|
||||||
const customViewDefaults = {
|
const customViewDefaults = {
|
||||||
mode: 'url',
|
mode: 'url',
|
||||||
url: '',
|
url: null,
|
||||||
|
widgetDef: null,
|
||||||
access: '',
|
access: '',
|
||||||
pluginId: '',
|
pluginId: '',
|
||||||
sectionId: ''
|
sectionId: ''
|
||||||
@ -196,11 +205,16 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
this.customDef = {
|
this.customDef = {
|
||||||
mode: customDefObj.prop('mode'),
|
mode: customDefObj.prop('mode'),
|
||||||
url: customDefObj.prop('url'),
|
url: customDefObj.prop('url'),
|
||||||
|
widgetDef: customDefObj.prop('widgetDef'),
|
||||||
access: customDefObj.prop('access'),
|
access: customDefObj.prop('access'),
|
||||||
pluginId: customDefObj.prop('pluginId'),
|
pluginId: customDefObj.prop('pluginId'),
|
||||||
sectionId: customDefObj.prop('sectionId')
|
sectionId: customDefObj.prop('sectionId')
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.saveCustomDef = () => {
|
||||||
|
return customDefObj.save();
|
||||||
|
};
|
||||||
|
|
||||||
this.themeDef = modelUtil.fieldWithDefault(this.theme, 'form');
|
this.themeDef = modelUtil.fieldWithDefault(this.theme, 'form');
|
||||||
this.chartTypeDef = modelUtil.fieldWithDefault(this.chartType, 'bar');
|
this.chartTypeDef = modelUtil.fieldWithDefault(this.chartType, 'bar');
|
||||||
this.view = refRecord(docModel.views, this.parentId);
|
this.view = refRecord(docModel.views, this.parentId);
|
||||||
|
@ -123,7 +123,12 @@ export function reportError(err: Error|string): void {
|
|||||||
} else {
|
} else {
|
||||||
// If we don't recognize it, consider it an application error (bug) that the user should be
|
// If we don't recognize it, consider it an application error (bug) that the user should be
|
||||||
// able to report.
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
280
app/client/ui/CustomSectionConfig.ts
Normal file
280
app/client/ui/CustomSectionConfig.ts
Normal 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};
|
||||||
|
}
|
||||||
|
`);
|
@ -40,6 +40,7 @@ import {bundleChanges, Computed, Disposable, dom, domComputed, DomContents,
|
|||||||
DomElementArg, DomElementMethod, IDomComponent} from 'grainjs';
|
DomElementArg, DomElementMethod, IDomComponent} from 'grainjs';
|
||||||
import {MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
import {MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
|
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
||||||
|
|
||||||
// Represents a top tab of the right side-pane.
|
// Represents a top tab of the right side-pane.
|
||||||
const TopTab = StringUnion("pageWidget", "field");
|
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
|
// In the default url mode, allow picking a url and granting/forbidding
|
||||||
// access to data.
|
// access to data.
|
||||||
dom.maybe(use => use(activeSection.customDef.mode) === 'url',
|
dom.maybe(use => use(activeSection.customDef.mode) === 'url',
|
||||||
() => dom('div', parts[1].buildDom())),
|
() => dom.create(CustomSectionConfig, activeSection, this._gristDoc.app.topAppModel.api)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
]),
|
]),
|
||||||
|
39
app/common/CustomWidget.ts
Normal file
39
app/common/CustomWidget.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Custom widget manifest definition.
|
||||||
|
*/
|
||||||
|
export interface ICustomWidget {
|
||||||
|
/**
|
||||||
|
* Widget friendly name, used on the UI.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Widget unique id, probably in npm package format @gristlabs/custom-widget-name.
|
||||||
|
*/
|
||||||
|
widgetId: string;
|
||||||
|
/**
|
||||||
|
* Custom widget main page URL.
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* Optional desired access level.
|
||||||
|
*/
|
||||||
|
accessLevel?: AccessLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget access level.
|
||||||
|
*/
|
||||||
|
export enum AccessLevel {
|
||||||
|
/**
|
||||||
|
* Default, no access to Grist.
|
||||||
|
*/
|
||||||
|
none = "none",
|
||||||
|
/**
|
||||||
|
* Read only access to table the widget is based on.
|
||||||
|
*/
|
||||||
|
read_table = "read table",
|
||||||
|
/**
|
||||||
|
* Full access to document on user's behalf.
|
||||||
|
*/
|
||||||
|
full = "full",
|
||||||
|
}
|
@ -6,6 +6,7 @@ import {BrowserSettings} from 'app/common/BrowserSettings';
|
|||||||
import {BulkColValues, TableColValues, UserAction} from 'app/common/DocActions';
|
import {BulkColValues, TableColValues, UserAction} from 'app/common/DocActions';
|
||||||
import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
|
import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
|
||||||
import {Features} from 'app/common/Features';
|
import {Features} from 'app/common/Features';
|
||||||
|
import {ICustomWidget} from 'app/common/CustomWidget';
|
||||||
import {isClient} from 'app/common/gristUrls';
|
import {isClient} from 'app/common/gristUrls';
|
||||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||||
import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs';
|
import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs';
|
||||||
@ -321,6 +322,7 @@ export interface UserAPI {
|
|||||||
deleteUser(userId: number, name: string): Promise<void>;
|
deleteUser(userId: number, name: string): Promise<void>;
|
||||||
getBaseUrl(): string; // Get the prefix for all the endpoints this object wraps.
|
getBaseUrl(): string; // Get the prefix for all the endpoints this object wraps.
|
||||||
forRemoved(): UserAPI; // Get a version of the API that works on removed resources.
|
forRemoved(): UserAPI; // Get a version of the API that works on removed resources.
|
||||||
|
getWidgets(): Promise<ICustomWidget[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -428,6 +430,10 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
|||||||
return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' });
|
return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getWidgets(): Promise<ICustomWidget[]> {
|
||||||
|
return await this.requestJson(`${this._url}/api/widgets`, { method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
public async getDoc(docId: string): Promise<Document> {
|
public async getDoc(docId: string): Promise<Document> {
|
||||||
return this.requestJson(`${this._url}/api/docs/${docId}`, { method: 'GET' });
|
return this.requestJson(`${this._url}/api/docs/${docId}`, { method: 'GET' });
|
||||||
}
|
}
|
||||||
|
@ -469,6 +469,9 @@ export interface GristLoadConfig {
|
|||||||
|
|
||||||
// List of registered plugins (used by HomePluginManager and DocPluginManager)
|
// List of registered plugins (used by HomePluginManager and DocPluginManager)
|
||||||
plugins?: LocalPlugin[];
|
plugins?: LocalPlugin[];
|
||||||
|
|
||||||
|
// If custom widget list is available.
|
||||||
|
enableWidgetRepository?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Acceptable org subdomains are alphanumeric (hyphen also allowed) and of
|
// Acceptable org subdomains are alphanumeric (hyphen also allowed) and of
|
||||||
|
@ -12,6 +12,7 @@ import {RequestWithOrg} from 'app/server/lib/extractOrg';
|
|||||||
import * as log from 'app/server/lib/log';
|
import * as log from 'app/server/lib/log';
|
||||||
import {addPermit, getDocScope, getScope, integerParam, isParameterOn, sendOkReply,
|
import {addPermit, getDocScope, getScope, integerParam, isParameterOn, sendOkReply,
|
||||||
sendReply, stringParam} from 'app/server/lib/requestUtils';
|
sendReply, stringParam} from 'app/server/lib/requestUtils';
|
||||||
|
import {IWidgetRepository} from 'app/server/lib/WidgetRepository';
|
||||||
import {Request} from 'express';
|
import {Request} from 'express';
|
||||||
|
|
||||||
import {User} from './entity/User';
|
import {User} from './entity/User';
|
||||||
@ -98,7 +99,8 @@ export class ApiServer {
|
|||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private _app: express.Application,
|
private _app: express.Application,
|
||||||
private _dbManager: HomeDBManager
|
private _dbManager: HomeDBManager,
|
||||||
|
private _widgetRepository: IWidgetRepository
|
||||||
) {
|
) {
|
||||||
this._addEndpoints();
|
this._addEndpoints();
|
||||||
}
|
}
|
||||||
@ -238,6 +240,13 @@ export class ApiServer {
|
|||||||
return sendReply(req, res, query);
|
return sendReply(req, res, query);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// GET /api/widgets/
|
||||||
|
// Get all widget definitions from external source.
|
||||||
|
this._app.get('/api/widgets/', expressWrap(async (req, res) => {
|
||||||
|
const widgetList = await this._widgetRepository.getWidgets();
|
||||||
|
return sendOkReply(req, res, widgetList);
|
||||||
|
}));
|
||||||
|
|
||||||
// PATCH /api/docs/:did
|
// PATCH /api/docs/:did
|
||||||
// Update the specified doc.
|
// Update the specified doc.
|
||||||
this._app.patch('/api/docs/:did', expressWrap(async (req, res) => {
|
this._app.patch('/api/docs/:did', expressWrap(async (req, res) => {
|
||||||
|
@ -54,6 +54,7 @@ import * as shutdown from 'app/server/lib/shutdown';
|
|||||||
import {TagChecker} from 'app/server/lib/TagChecker';
|
import {TagChecker} from 'app/server/lib/TagChecker';
|
||||||
import {startTestingHooks} from 'app/server/lib/TestingHooks';
|
import {startTestingHooks} from 'app/server/lib/TestingHooks';
|
||||||
import {addUploadRoute} from 'app/server/lib/uploads';
|
import {addUploadRoute} from 'app/server/lib/uploads';
|
||||||
|
import {buildWidgetRepository, IWidgetRepository} from 'app/server/lib/WidgetRepository';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as bodyParser from 'body-parser';
|
import * as bodyParser from 'body-parser';
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
@ -121,6 +122,7 @@ export class FlexServer implements GristServer {
|
|||||||
private _sessionStore: SessionStore;
|
private _sessionStore: SessionStore;
|
||||||
private _storageManager: IDocStorageManager;
|
private _storageManager: IDocStorageManager;
|
||||||
private _docWorkerMap: IDocWorkerMap;
|
private _docWorkerMap: IDocWorkerMap;
|
||||||
|
private _widgetRepository: IWidgetRepository;
|
||||||
private _internalPermitStore: IPermitStore; // store for permits that stay within our servers
|
private _internalPermitStore: IPermitStore; // store for permits that stay within our servers
|
||||||
private _externalPermitStore: IPermitStore; // store for permits that pass through outside servers
|
private _externalPermitStore: IPermitStore; // store for permits that pass through outside servers
|
||||||
private _disabled: boolean = false;
|
private _disabled: boolean = false;
|
||||||
@ -271,6 +273,11 @@ export class FlexServer implements GristServer {
|
|||||||
return this._storageManager;
|
return this._storageManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getWidgetRepository(): IWidgetRepository {
|
||||||
|
if (!this._widgetRepository) { throw new Error('no widget repository available'); }
|
||||||
|
return this._widgetRepository;
|
||||||
|
}
|
||||||
|
|
||||||
public addLogging() {
|
public addLogging() {
|
||||||
if (this._check('logging')) { return; }
|
if (this._check('logging')) { return; }
|
||||||
if (process.env.GRIST_LOG_SKIP_HTTP) { return; }
|
if (process.env.GRIST_LOG_SKIP_HTTP) { return; }
|
||||||
@ -524,7 +531,7 @@ export class FlexServer implements GristServer {
|
|||||||
|
|
||||||
// ApiServer's constructor adds endpoints to the app.
|
// ApiServer's constructor adds endpoints to the app.
|
||||||
// tslint:disable-next-line:no-unused-expression
|
// tslint:disable-next-line:no-unused-expression
|
||||||
new ApiServer(this.app, this._dbManager);
|
new ApiServer(this.app, this._dbManager, this._widgetRepository = buildWidgetRepository());
|
||||||
}
|
}
|
||||||
|
|
||||||
public addBillingApi() {
|
public addBillingApi() {
|
||||||
|
@ -19,6 +19,7 @@ export const ITestingHooks = t.iface([], {
|
|||||||
"getDocClientCounts": t.func(t.array(t.tuple("string", "number"))),
|
"getDocClientCounts": t.func(t.array(t.tuple("string", "number"))),
|
||||||
"setActiveDocTimeout": t.func("number", t.param("seconds", "number")),
|
"setActiveDocTimeout": t.func("number", t.param("seconds", "number")),
|
||||||
"setDiscourseConnectVar": t.func(t.union("string", "null"), t.param("varName", "string"), t.param("value", t.union("string", "null"))),
|
"setDiscourseConnectVar": t.func(t.union("string", "null"), t.param("varName", "string"), t.param("value", t.union("string", "null"))),
|
||||||
|
"setWidgetRepositoryUrl": t.func("void", t.param("url", "string")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const exportedTypeSuite: t.ITypeSuite = {
|
const exportedTypeSuite: t.ITypeSuite = {
|
||||||
|
@ -15,4 +15,5 @@ export interface ITestingHooks {
|
|||||||
getDocClientCounts(): Promise<Array<[string, number]>>;
|
getDocClientCounts(): Promise<Array<[string, number]>>;
|
||||||
setActiveDocTimeout(seconds: number): Promise<number>;
|
setActiveDocTimeout(seconds: number): Promise<number>;
|
||||||
setDiscourseConnectVar(varName: string, value: string|null): Promise<string|null>;
|
setDiscourseConnectVar(varName: string, value: string|null): Promise<string|null>;
|
||||||
|
setWidgetRepositoryUrl(url: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ import {FlexServer} from './FlexServer';
|
|||||||
import {ITestingHooks} from './ITestingHooks';
|
import {ITestingHooks} from './ITestingHooks';
|
||||||
import ITestingHooksTI from './ITestingHooks-ti';
|
import ITestingHooksTI from './ITestingHooks-ti';
|
||||||
import {connect, fromCallback} from './serverUtils';
|
import {connect, fromCallback} from './serverUtils';
|
||||||
|
import {WidgetRepositoryImpl} from 'app/server/lib/WidgetRepository';
|
||||||
|
|
||||||
const tiCheckers = t.createCheckers(ITestingHooksTI, {UserProfile: t.name("object")});
|
const tiCheckers = t.createCheckers(ITestingHooksTI, {UserProfile: t.name("object")});
|
||||||
|
|
||||||
@ -194,4 +195,12 @@ export class TestingHooks implements ITestingHooks {
|
|||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setWidgetRepositoryUrl(url: string): Promise<void> {
|
||||||
|
const repo = this._server.getWidgetRepository() as WidgetRepositoryImpl;
|
||||||
|
if (!(repo instanceof WidgetRepositoryImpl)) {
|
||||||
|
throw new Error("Unsupported widget repository");
|
||||||
|
}
|
||||||
|
repo.testOverrideUrl(url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
92
app/server/lib/WidgetRepository.ts
Normal file
92
app/server/lib/WidgetRepository.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import {ICustomWidget} from 'app/common/CustomWidget';
|
||||||
|
import * as log from 'app/server/lib/log';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import {ApiError} from 'app/common/ApiError';
|
||||||
|
import * as LRUCache from 'lru-cache';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget Repository returns list of available Custom Widgets.
|
||||||
|
*/
|
||||||
|
export interface IWidgetRepository {
|
||||||
|
getWidgets(): Promise<ICustomWidget[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static url for StaticWidgetRepository
|
||||||
|
const STATIC_URL = process.env.GRIST_WIDGET_LIST_URL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default repository that gets list of available widgets from a static URL.
|
||||||
|
*/
|
||||||
|
export class WidgetRepositoryImpl implements IWidgetRepository {
|
||||||
|
constructor(protected _staticUrl = STATIC_URL) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method exposed for testing, overrides widget url.
|
||||||
|
*/
|
||||||
|
public testOverrideUrl(url: string) {
|
||||||
|
this._staticUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getWidgets(): Promise<ICustomWidget[]> {
|
||||||
|
if (!this._staticUrl) {
|
||||||
|
log.warn(
|
||||||
|
'WidgetRepository: Widget repository is not configured.' + !STATIC_URL
|
||||||
|
? ' Missing GRIST_WIDGET_LIST_URL environmental variable.'
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(this._staticUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new ApiError('WidgetRepository: Remote widget list not found', 404);
|
||||||
|
} else {
|
||||||
|
const body = await response.text().catch(() => '');
|
||||||
|
throw new ApiError(
|
||||||
|
`WidgetRepository: Remote server returned an error: ${body || response.statusText}`, response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const widgets = await response.json().catch(() => null);
|
||||||
|
if (!widgets || !Array.isArray(widgets)) {
|
||||||
|
throw new ApiError('WidgetRepository: Error reading widget list', 500);
|
||||||
|
}
|
||||||
|
return widgets;
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiError)) {
|
||||||
|
throw new ApiError(String(err), 500);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version of WidgetRepository that caches successful result for 2 minutes.
|
||||||
|
*/
|
||||||
|
class CachedWidgetRepository extends WidgetRepositoryImpl {
|
||||||
|
private _cache = new LRUCache<1, ICustomWidget[]>({maxAge : 1000 * 60 /* minute */ * 2});
|
||||||
|
public async getWidgets() {
|
||||||
|
if (this._cache.has(1)) {
|
||||||
|
log.debug("WidgetRepository: Widget list taken from the cache.");
|
||||||
|
return this._cache.get(1)!;
|
||||||
|
}
|
||||||
|
const list = await super.getWidgets();
|
||||||
|
// Cache only if there are some widgets.
|
||||||
|
if (list.length) { this._cache.set(1, list); }
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public testOverrideUrl(url: string) {
|
||||||
|
super.testOverrideUrl(url);
|
||||||
|
this._cache.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns widget repository implementation.
|
||||||
|
*/
|
||||||
|
export function buildWidgetRepository() {
|
||||||
|
return new CachedWidgetRepository();
|
||||||
|
}
|
@ -45,6 +45,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial<GristLoadCo
|
|||||||
maxUploadSizeImport: (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || undefined,
|
maxUploadSizeImport: (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || undefined,
|
||||||
maxUploadSizeAttachment: (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || undefined,
|
maxUploadSizeAttachment: (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || undefined,
|
||||||
timestampMs: Date.now(),
|
timestampMs: Date.now(),
|
||||||
|
enableWidgetRepository: Boolean(process.env.GRIST_WIDGET_LIST_URL),
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user