mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
cc9a9ae8c5
Summary: This adds support for bundling custom widgets with the Grist app, as follows: * Adds a new `widgets` component to plugins mechanism. * When a set of widgets is provided in a plugin, the html/js/css assets for those widgets are served on the existing untrusted user content port. * Any bundled `grist-plugin-api.js` will be served with the Grist app's own version of that file. It is important that bundled widgets not refer to https://docs.getgrist.com for the plugin js, since they must be capable of working offline. * The logic for configuring that port is updated a bit. * I removed the CustomAttachedView class in favor of applying settings of bundled custom widgets more directly, without modification on view. Any Grist installation via docker will need an extra step now, since there is an extra port that needs exposing for full functionality. I did add a `GRIST_TRUST_PLUGINS` option for anyone who really doesn't want to do this, and would prefer to trust the plugins and have them served on the same port. Actually making use of bundling will be another step. It'll be important to mesh it with our SaaS's use of APP_STATIC_URL for serving most static assets. Design sketch: https://grist.quip.com/bJlWACWzr2R9/Bundled-custom-widgets Test Plan: added a test Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4069
772 lines
26 KiB
TypeScript
772 lines
26 KiB
TypeScript
import {allCommands} from 'app/client/components/commands';
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
|
import {makeTestId} from 'app/client/lib/domUtils';
|
|
import * as kf from 'app/client/lib/koForm';
|
|
import {makeT} from 'app/client/lib/localization';
|
|
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
|
|
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
|
import {reportError} from 'app/client/models/errors';
|
|
import {cssHelp, cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
|
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
|
import {cssDragRow, cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig';
|
|
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
|
import {theme, vars} from 'app/client/ui2018/cssVars';
|
|
import {cssDragger} from 'app/client/ui2018/draggableList';
|
|
import {textInput} from 'app/client/ui2018/editableLabel';
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
import {cssLink} from 'app/client/ui2018/links';
|
|
import {cssOptionLabel, IOption, IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
|
|
import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
|
|
import {GristLoadConfig} from 'app/common/gristUrls';
|
|
import {not, unwrap} from 'app/common/gutil';
|
|
import {
|
|
bundleChanges,
|
|
Computed,
|
|
Disposable,
|
|
dom,
|
|
fromKo,
|
|
MultiHolder,
|
|
Observable,
|
|
styled,
|
|
UseCBOwner
|
|
} from 'grainjs';
|
|
|
|
const t = makeT('CustomSectionConfig');
|
|
|
|
// 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 the custom URL.
|
|
*/
|
|
|
|
class ColumnPicker extends Disposable {
|
|
constructor(
|
|
private _value: Observable<number|number[]|null>,
|
|
private _column: ColumnToMapImpl,
|
|
private _section: ViewSectionRec){
|
|
super();
|
|
}
|
|
public buildDom() {
|
|
// Rewrite value to ignore old configuration when allowMultiple is switched.
|
|
const properValue = Computed.create(this, use => {
|
|
const value = use(this._value);
|
|
return Array.isArray(value) ? null : value;
|
|
});
|
|
properValue.onWrite(value => this._value.set(value || null));
|
|
|
|
const canBeMapped = Computed.create(this, use => {
|
|
return use(this._section.columns)
|
|
.filter(col => this._column.canByMapped(use(col.pureType)));
|
|
});
|
|
|
|
// This is a HACK, to refresh options only when the menu is opened (or closed)
|
|
// and not to track down all the dependencies. Otherwise the select menu won't
|
|
// be hidden when option is selected - there is a bug that prevents it from closing
|
|
// when list of options is changed.
|
|
const refreshTrigger = Observable.create(this, false);
|
|
|
|
const options = Computed.create(this, use => {
|
|
void use(refreshTrigger);
|
|
|
|
const columnsAsOptions: IOption<number|null>[] = use(canBeMapped)
|
|
.map((col) => ({
|
|
value: col.getRowId(),
|
|
label: col.label.peek(),
|
|
icon: 'FieldColumn',
|
|
}));
|
|
|
|
// For optional mappings, add 'Blank' option but only if the value is set.
|
|
// This option will allow to clear the selection.
|
|
if (this._column.optional && properValue.get()) {
|
|
columnsAsOptions.push({
|
|
value: 0,
|
|
// Another hack. Select doesn't allow to have different label for blank option and the default text.
|
|
// So we will render this label ourselves later using `renderOptionArgs`.
|
|
label: '',
|
|
});
|
|
}
|
|
return columnsAsOptions;
|
|
});
|
|
|
|
const isDisabled = Computed.create(this, use => {
|
|
return use(canBeMapped).length === 0;
|
|
});
|
|
|
|
const defaultLabel = this._column.typeDesc != "any"
|
|
? t("Pick a {{columnType}} column", {"columnType": this._column.typeDesc})
|
|
: t("Pick a column");
|
|
|
|
return [
|
|
cssLabel(
|
|
this._column.title,
|
|
this._column.optional ? cssSubLabel(t(" (optional)")) : null,
|
|
testId('label-for-' + this._column.name),
|
|
),
|
|
this._column.description ? cssHelp(
|
|
this._column.description,
|
|
testId('help-for-' + this._column.name),
|
|
) : null,
|
|
dom.maybe(not(isDisabled), () => [
|
|
cssRow(
|
|
dom.update(
|
|
select(
|
|
properValue,
|
|
options,
|
|
{
|
|
defaultLabel,
|
|
renderOptionArgs : (opt) => {
|
|
// If there is a label, render it.
|
|
// Otherwise show the 'Clear selection' label as a greyed out text.
|
|
// This is the continuation of the hack from above - were we added an option
|
|
// without a label.
|
|
return (opt.label) ? null : [
|
|
cssBlank(t("Clear selection")),
|
|
testId('clear-selection'),
|
|
];
|
|
}
|
|
}
|
|
),
|
|
dom.on('click', () => {
|
|
// When the menu is opened or closed, refresh the options.
|
|
refreshTrigger.set(!refreshTrigger.get());
|
|
})
|
|
),
|
|
testId('mapping-for-' + this._column.name),
|
|
testId('enabled'),
|
|
),
|
|
]),
|
|
dom.maybe(isDisabled, () => [
|
|
cssRow(
|
|
cssDisabledSelect(
|
|
Observable.create(this, null),
|
|
[], {
|
|
disabled: true,
|
|
defaultLabel: t("No {{columnType}} columns in table.", {"columnType": this._column.typeDesc})
|
|
}
|
|
),
|
|
hoverTooltip(t("No {{columnType}} columns in table.", {"columnType": this._column.typeDesc})),
|
|
testId('mapping-for-' + this._column.name),
|
|
testId('disabled'),
|
|
),
|
|
]),
|
|
];
|
|
}
|
|
}
|
|
|
|
class ColumnListPicker extends Disposable {
|
|
constructor(
|
|
private _value: Observable<number|number[]|null>,
|
|
private _column: ColumnToMapImpl,
|
|
private _section: ViewSectionRec) {
|
|
super();
|
|
}
|
|
public buildDom() {
|
|
return dom.domComputed((use) => {
|
|
return [
|
|
cssLabel(this._column.title,
|
|
cssLabel.cls("-required", !this._column.optional),
|
|
testId('label-for-' + this._column.name),
|
|
),
|
|
this._buildDraggableList(use),
|
|
this._buildAddColumn()
|
|
];
|
|
});
|
|
}
|
|
private _buildAddColumn() {
|
|
|
|
const owner = MultiHolder.create(null);
|
|
|
|
const notMapped = Computed.create(owner, use => {
|
|
const value = use(this._value) || [];
|
|
const mapped = !Array.isArray(value) ? [] : value;
|
|
return this._section.columns().filter(col => !mapped.includes(use(col.id)));
|
|
});
|
|
|
|
const typedColumns = Computed.create(owner, use => {
|
|
return use(notMapped).filter(this._typeFilter(use));
|
|
});
|
|
|
|
return [
|
|
cssRow(
|
|
dom.autoDispose(owner),
|
|
cssAddMapping(
|
|
cssAddIcon('Plus'), t("Add") + ' ' + this._column.title,
|
|
dom.cls('disabled', use => use(notMapped).length === 0),
|
|
testId('disabled', use => use(notMapped).length === 0),
|
|
menu(() => {
|
|
const wrongTypeCount = notMapped.get().length - typedColumns.get().length;
|
|
return [
|
|
...typedColumns.get()
|
|
.map((col) => menuItem(
|
|
() => this._addColumn(col),
|
|
col.label.peek(),
|
|
)),
|
|
wrongTypeCount > 0 ? menuText(
|
|
t("{{wrongTypeCount}} non-{{columnType}} columns are not shown", {
|
|
wrongTypeCount,
|
|
columnType: this._column.type.toLowerCase(),
|
|
count: wrongTypeCount
|
|
}),
|
|
testId('map-message-' + this._column.name)
|
|
) : null
|
|
];
|
|
}),
|
|
testId('add-column-for-' + this._column.name),
|
|
)
|
|
),
|
|
];
|
|
}
|
|
|
|
// Helper method for filtering columns that can be picked by the widget.
|
|
private _typeFilter = (use = unwrap) => (col: ColumnRec|null) =>
|
|
!col ? false : this._column.canByMapped(use(col.pureType));
|
|
|
|
private _buildDraggableList(use: UseCBOwner) {
|
|
return dom.update(kf.draggableList(
|
|
this._readItems(use),
|
|
this._renderItem.bind(this, use),
|
|
{
|
|
itemClass: cssDragRow.className,
|
|
reorder: this._reorder.bind(this),
|
|
receive: this._addColumn.bind(this),
|
|
drag_indicator: cssDragger,
|
|
}
|
|
), testId('map-list-for-' + this._column.name));
|
|
}
|
|
|
|
private _readItems(use: UseCBOwner): ColumnRec[] {
|
|
let selectedRefs = (use(this._value) || []) as number[];
|
|
// Ignore if configuration was changed from what it was saved.
|
|
if (!Array.isArray(selectedRefs)) {
|
|
selectedRefs = [];
|
|
}
|
|
// Filter columns by type - when column type has changed since mapping.
|
|
const columns = use(this._section.columns).filter(this._typeFilter(use));
|
|
const columnMap = new Map(columns.map(c => [c.id.peek(), c]));
|
|
// Remove any columns that are no longer there.
|
|
return selectedRefs.map(s => columnMap.get(s)!).filter(c => Boolean(c));
|
|
}
|
|
|
|
private _renderItem(use: UseCBOwner, field: ColumnRec): any {
|
|
return cssFieldEntry(
|
|
cssFieldLabel(
|
|
dom.text(field.label),
|
|
testId('ref-select-label'),
|
|
),
|
|
cssRemoveIcon(
|
|
'Remove',
|
|
dom.on('click', () => this._remove(field)),
|
|
testId('ref-select-remove'),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Helper method that for accessing mapped columns. Can be used to set and retrieve the value.
|
|
private _list(value: number[]): void
|
|
private _list(): number[]
|
|
private _list(value?: number[]) {
|
|
if (value) {
|
|
this._value.set(value);
|
|
} else {
|
|
let current = (this._value.get() || []) as number[];
|
|
// Ignore if the saved value is not a number list.
|
|
if (!Array.isArray(current)) {
|
|
current = [];
|
|
}
|
|
return current;
|
|
}
|
|
}
|
|
|
|
private _reorder(column: ColumnRec, nextColumn: ColumnRec|null): any {
|
|
const id = column.id.peek();
|
|
const nextId = nextColumn?.id.peek();
|
|
const currentList = this._list();
|
|
const indexOfId = currentList.indexOf(id);
|
|
// Remove element from the list.
|
|
currentList.splice(indexOfId, 1);
|
|
const indexOfNext = nextId ? currentList.indexOf(nextId) : currentList.length;
|
|
// Insert before next element or at the end.
|
|
currentList.splice(indexOfNext, 0, id);
|
|
this._list(currentList);
|
|
}
|
|
private _remove(column: ColumnRec): any {
|
|
const current = this._list();
|
|
this._value.set(current.filter(c => c != column.id.peek()));
|
|
}
|
|
private _addColumn(col: ColumnRec): any {
|
|
// Helper to find column model.
|
|
const model = (id: number) => this._section.columns().find(c => c.id.peek() === id) || null;
|
|
// Get the list of currently mapped columns.
|
|
let current = this._list();
|
|
// Add new column.
|
|
current.push(col.id.peek());
|
|
// Remove those that don't exists anymore.
|
|
current = current.filter(c => model(c));
|
|
// And those with wrong type.
|
|
current = current.filter(c => this._typeFilter()(model(c)));
|
|
this._value.set(current);
|
|
}
|
|
}
|
|
|
|
class CustomSectionConfigurationConfig extends Disposable{
|
|
// Does widget has custom configuration.
|
|
private readonly _hasConfiguration: Computed<boolean>;
|
|
constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {
|
|
super();
|
|
this._hasConfiguration = Computed.create(this, use => use(_section.hasCustomOptions));
|
|
}
|
|
public buildDom() {
|
|
// Show prompt, when desired access level is different from actual one.
|
|
return dom(
|
|
'div',
|
|
dom.maybe(this._hasConfiguration, () =>
|
|
cssSection(
|
|
textButton(
|
|
t("Open configuration"),
|
|
dom.on('click', () => this._openConfiguration()),
|
|
testId('open-configuration')
|
|
)
|
|
)
|
|
),
|
|
dom.maybeOwned(use => use(this._section.columnsToMap), (owner, columns) => {
|
|
const createObs = (column: ColumnToMapImpl) => {
|
|
const obs = Computed.create(owner, use => {
|
|
const savedDefinition = use(this._section.customDef.columnsMapping) || {};
|
|
return savedDefinition[column.name];
|
|
});
|
|
obs.onWrite(async (value) => {
|
|
const savedDefinition = this._section.customDef.columnsMapping.peek() || {};
|
|
savedDefinition[column.name] = value;
|
|
await this._section.customDef.columnsMapping.setAndSave(savedDefinition);
|
|
});
|
|
return obs;
|
|
};
|
|
// Create observables for all columns to pick.
|
|
const mappings = columns.map(c => new ColumnToMapImpl(c)).map((column) => ({
|
|
value: createObs(column),
|
|
column
|
|
}));
|
|
return dom('div',
|
|
this._attachColumnMappingTip(this._section.customDef.url()),
|
|
...mappings.map(m => m.column.allowMultiple
|
|
? dom.create(ColumnListPicker, m.value, m.column, this._section)
|
|
: dom.create(ColumnPicker, m.value, m.column, this._section)),
|
|
);
|
|
})
|
|
);
|
|
}
|
|
private _openConfiguration(): void {
|
|
allCommands.openWidgetConfiguration.run();
|
|
}
|
|
|
|
private _attachColumnMappingTip(widgetUrl: string | null) {
|
|
switch (widgetUrl) {
|
|
// TODO: come up with a way to attach tips without hardcoding widget URLs.
|
|
case 'https://gristlabs.github.io/grist-widget/calendar/index.html': {
|
|
return this._gristDoc.behavioralPromptsManager.attachTip('calendarConfig', {
|
|
popupOptions: {placement: 'left-start'},
|
|
});
|
|
}
|
|
default: {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export class CustomSectionConfig extends Disposable {
|
|
|
|
protected _customSectionConfigurationConfig: CustomSectionConfigurationConfig;
|
|
// Holds all available widget definitions.
|
|
private _widgets: Observable<ICustomWidget[]|null>;
|
|
// Holds selected option (either custom string or a widgetId).
|
|
private readonly _selectedId: Computed<string | null>;
|
|
// Holds custom widget URL.
|
|
private readonly _url: Computed<string>;
|
|
// Enable or disable widget repository.
|
|
private readonly _canSelect: boolean = true;
|
|
// When widget is changed, it sets its desired access level. We will prompt
|
|
// user to approve or reject it.
|
|
private readonly _desiredAccess: Observable<AccessLevel|null>;
|
|
// Current access level (stored inside a section).
|
|
private readonly _currentAccess: Computed<AccessLevel>;
|
|
|
|
|
|
|
|
|
|
constructor(protected _section: ViewSectionRec, private _gristDoc: GristDoc) {
|
|
super();
|
|
this._customSectionConfigurationConfig = new CustomSectionConfigurationConfig(_section, _gristDoc);
|
|
|
|
// 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 = _gristDoc.app.topAppModel.customWidgets;
|
|
this._getWidgets().catch(reportError);
|
|
// Request for rest of the widgets.
|
|
|
|
// Selected value from the dropdown (contains widgetId or "custom" string for Custom URL)
|
|
this._selectedId = Computed.create(this, use => {
|
|
// widgetId could be stored in one of two places, depending on
|
|
// age of document.
|
|
const widgetId = use(_section.customDef.widgetId) ||
|
|
use(_section.customDef.widgetDef)?.widgetId;
|
|
const pluginId = use(_section.customDef.pluginId);
|
|
if (widgetId) {
|
|
// selection id is "pluginId:widgetId"
|
|
return (pluginId || '') + ':' + widgetId;
|
|
}
|
|
return CUSTOM_ID;
|
|
});
|
|
this._selectedId.onWrite(async value => {
|
|
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 widgetId
|
|
_section.customDef.widgetId(null);
|
|
_section.customDef.widgetDef(null);
|
|
// Clear pluginId
|
|
_section.customDef.pluginId('');
|
|
// Reset access level to none.
|
|
_section.customDef.access(AccessLevel.none);
|
|
// Clear all saved options.
|
|
_section.customDef.widgetOptions(null);
|
|
// Reset custom configuration flag.
|
|
_section.hasCustomOptions(false);
|
|
// Clear column mappings.
|
|
_section.customDef.columnsMapping(null);
|
|
_section.columnsToMap(null);
|
|
this._desiredAccess.set(AccessLevel.none);
|
|
});
|
|
await _section.saveCustomDef();
|
|
} else {
|
|
const [pluginId, widgetId] = value?.split(':') || [];
|
|
// Select Widget
|
|
const selectedWidget = matchWidget(this._widgets.get()||[], {
|
|
widgetId,
|
|
pluginId,
|
|
});
|
|
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.widgetId.peek() === widgetId &&
|
|
_section.customDef.pluginId.peek() === pluginId) {
|
|
return;
|
|
}
|
|
bundleChanges(() => {
|
|
// Reset whether widget should render after `grist.ready()`.
|
|
_section.customDef.renderAfterReady(selectedWidget.renderAfterReady ?? false);
|
|
// Clear access level
|
|
_section.customDef.access(AccessLevel.none);
|
|
// When widget wants some access, set desired access level.
|
|
this._desiredAccess.set(selectedWidget.accessLevel || AccessLevel.none);
|
|
|
|
// Keep a record of the original widget definition.
|
|
// Don't rely on this much, since the document could
|
|
// have moved installation since, and widgets could be
|
|
// served from elsewhere.
|
|
_section.customDef.widgetDef(selectedWidget);
|
|
|
|
// Update widgetId.
|
|
_section.customDef.widgetId(selectedWidget.widgetId);
|
|
// Update pluginId.
|
|
_section.customDef.pluginId(selectedWidget.source?.pluginId || '');
|
|
// Update widget URL. Leave blank when widgetId is set.
|
|
_section.customDef.url(null);
|
|
// Clear options.
|
|
_section.customDef.widgetOptions(null);
|
|
// Clear has custom configuration.
|
|
_section.hasCustomOptions(false);
|
|
// Clear column mappings.
|
|
_section.customDef.columnsMapping(null);
|
|
_section.columnsToMap(null);
|
|
});
|
|
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(async newUrl => {
|
|
bundleChanges(() => {
|
|
_section.customDef.renderAfterReady(false);
|
|
if (newUrl) {
|
|
// When a URL is set explicitly, make sure widgetId/pluginId/widgetDef
|
|
// is empty.
|
|
_section.customDef.widgetId(null);
|
|
_section.customDef.pluginId('');
|
|
_section.customDef.widgetDef(null);
|
|
}
|
|
_section.customDef.url(newUrl);
|
|
});
|
|
await _section.saveCustomDef();
|
|
});
|
|
|
|
// Compute current access level.
|
|
this._currentAccess = Computed.create(
|
|
this,
|
|
use => (use(_section.customDef.access) as AccessLevel) || AccessLevel.none
|
|
);
|
|
this._currentAccess.onWrite(async newAccess => {
|
|
await _section.customDef.access.setAndSave(newAccess);
|
|
});
|
|
// From the start desired access level is the same as current one.
|
|
this._desiredAccess = fromKo(_section.desiredAccessLevel);
|
|
|
|
// Clear intermediate state when section changes.
|
|
this.autoDispose(_section.id.subscribe(() => 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._desiredAccess)
|
|
&& !isSatisfied(use(this._currentAccess), use(this._desiredAccess)!));
|
|
// If this is empty section or not.
|
|
const isSelected = Computed.create(holder, use => Boolean(use(this._selectedId)));
|
|
// If user is using custom url.
|
|
const isCustom = Computed.create(holder, use => use(this._selectedId) === CUSTOM_ID || !this._canSelect);
|
|
// Options for the select-box (all widgets definitions and Custom URL)
|
|
const options = Computed.create(holder, use => [
|
|
{label: 'Custom URL', value: 'custom'},
|
|
...(use(this._widgets) || []).map(w => ({
|
|
label: w.source?.name ? `${w.name} (${w.source.name})` : w.name,
|
|
value: (w.source?.pluginId || '') + ':' + w.widgetId,
|
|
})),
|
|
]);
|
|
function buildPrompt(level: AccessLevel|null) {
|
|
if (!level) {
|
|
return null;
|
|
}
|
|
switch(level) {
|
|
case AccessLevel.none: return cssConfirmLine(t("Widget does not require any permissions."));
|
|
case AccessLevel.read_table:
|
|
return cssConfirmLine(t("Widget needs to {{read}} the current table.", {read: dom("b", "read")}));
|
|
case AccessLevel.full:
|
|
return cssConfirmLine(t("Widget needs {{fullAccess}} to this document.", {
|
|
fullAccess: dom("b", "full access")
|
|
}));
|
|
default: throw new Error(`Unsupported ${level} access level`);
|
|
}
|
|
}
|
|
// Options for access level.
|
|
const levels: IOptionFull<string>[] = [
|
|
{label: t("No document access"), value: AccessLevel.none},
|
|
{label: t("Read selected table"), value: AccessLevel.read_table},
|
|
{label: t("Full document access"), value: AccessLevel.full},
|
|
];
|
|
return dom(
|
|
'div',
|
|
dom.autoDispose(holder),
|
|
this.shouldRenderWidgetSelector() &&
|
|
this._canSelect
|
|
? cssRow(
|
|
select(this._selectedId, options, {
|
|
defaultLabel: t("Select Custom Widget"),
|
|
menuCssClass: cssMenu.className,
|
|
}),
|
|
testId('select')
|
|
)
|
|
: null,
|
|
dom.maybe((use) => use(isCustom) && this.shouldRenderWidgetSelector(), () => [
|
|
cssRow(
|
|
cssTextInput(
|
|
this._url,
|
|
async value => this._url.set(value),
|
|
dom.attr('placeholder', t("Enter Custom URL")),
|
|
testId('url')
|
|
),
|
|
this._gristDoc.behavioralPromptsManager.attachTip('customURL', {
|
|
popupOptions: {
|
|
placement: 'left-start',
|
|
},
|
|
isDisabled: () => {
|
|
// Disable tip if a custom widget is already selected.
|
|
return Boolean(this._selectedId.get() && !(isCustom.get() && this._url.get().trim() === ''));
|
|
},
|
|
})
|
|
),
|
|
]),
|
|
dom.maybe(prompt, () =>
|
|
kf.prompt(
|
|
{tabindex: '-1'},
|
|
cssColumns(
|
|
cssWarningWrapper(icon('Lock')),
|
|
dom(
|
|
'div',
|
|
cssConfirmRow(
|
|
dom.domComputed(this._desiredAccess, (level) => buildPrompt(level))
|
|
),
|
|
cssConfirmRow(
|
|
primaryButton(
|
|
'Accept',
|
|
testId('access-accept'),
|
|
dom.on('click', () => this._accept())
|
|
),
|
|
basicButton(
|
|
'Reject',
|
|
testId('access-reject'),
|
|
dom.on('click', () => this._reject())
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
),
|
|
dom.maybe(
|
|
use => use(isSelected) || !this._canSelect,
|
|
() => [
|
|
cssLabel('ACCESS LEVEL'),
|
|
cssRow(select(this._currentAccess, levels), testId('access')),
|
|
]
|
|
),
|
|
cssSection(
|
|
cssLink(
|
|
dom.attr('href', 'https://support.getgrist.com/widget-custom'),
|
|
dom.attr('target', '_blank'),
|
|
t("Learn more about custom widgets")
|
|
)
|
|
),
|
|
cssSeparator(),
|
|
this._customSectionConfigurationConfig.buildDom(),
|
|
);
|
|
}
|
|
|
|
protected shouldRenderWidgetSelector(): boolean {
|
|
return true;
|
|
}
|
|
|
|
protected async _getWidgets() {
|
|
await this._gristDoc.app.topAppModel.getWidgets();
|
|
}
|
|
|
|
private _accept() {
|
|
if (this._desiredAccess.get()) {
|
|
this._currentAccess.set(this._desiredAccess.get()!);
|
|
}
|
|
this._reject();
|
|
}
|
|
|
|
private _reject() {
|
|
this._desiredAccess.set(null);
|
|
}
|
|
}
|
|
|
|
const cssWarningWrapper = styled('div', `
|
|
padding-left: 8px;
|
|
padding-top: 6px;
|
|
--icon-color: ${theme.iconError}
|
|
`);
|
|
|
|
const cssColumns = styled('div', `
|
|
display: flex;
|
|
`);
|
|
|
|
const cssConfirmRow = styled('div', `
|
|
display: flex;
|
|
padding: 8px;
|
|
gap: 8px;
|
|
`);
|
|
|
|
const cssConfirmLine = styled('span', `
|
|
white-space: pre-wrap;
|
|
`);
|
|
|
|
const cssSection = styled('div', `
|
|
margin: 16px 16px 12px 16px;
|
|
`);
|
|
|
|
const cssMenu = styled('div', `
|
|
& > li:first-child {
|
|
border-bottom: 1px solid ${theme.menuBorder};
|
|
}
|
|
`);
|
|
|
|
const cssAddIcon = styled(icon, `
|
|
margin-right: 4px;
|
|
`);
|
|
|
|
const cssRemoveIcon = styled(icon, `
|
|
display: none;
|
|
cursor: pointer;
|
|
flex: none;
|
|
margin-left: 8px;
|
|
.${cssFieldEntry.className}:hover & {
|
|
display: block;
|
|
}
|
|
`);
|
|
|
|
// Additional text in label (greyed out)
|
|
const cssSubLabel = styled('span', `
|
|
text-transform: none;
|
|
font-size: ${vars.xsmallFontSize};
|
|
color: ${theme.lightText};
|
|
`);
|
|
|
|
const cssAddMapping = styled('div', `
|
|
display: flex;
|
|
cursor: pointer;
|
|
color: ${theme.controlFg};
|
|
--icon-color: ${theme.controlFg};
|
|
|
|
&:not(:first-child) {
|
|
margin-top: 8px;
|
|
}
|
|
&:hover, &:focus, &:active {
|
|
color: ${theme.controlHoverFg};
|
|
--icon-color: ${theme.controlHoverFg};
|
|
}
|
|
&.disabled {
|
|
color: ${theme.lightText};
|
|
--icon-color: ${theme.lightText};
|
|
pointer-events: none;
|
|
}
|
|
`);
|
|
|
|
const cssTextInput = styled(textInput, `
|
|
flex: 1 0 auto;
|
|
|
|
color: ${theme.inputFg};
|
|
background-color: ${theme.inputBg};
|
|
|
|
&:disabled {
|
|
color: ${theme.inputDisabledFg};
|
|
background-color: ${theme.inputDisabledBg};
|
|
pointer-events: none;
|
|
}
|
|
|
|
&::placeholder {
|
|
color: ${theme.inputPlaceholderFg};
|
|
}
|
|
`);
|
|
|
|
const cssDisabledSelect = styled(select, `
|
|
opacity: unset !important;
|
|
`);
|
|
|
|
const cssBlank = styled(cssOptionLabel, `
|
|
--grist-option-label-color: ${theme.lightText};
|
|
`);
|