mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Add custom widget gallery
Summary: Custom widgets are now shown in a gallery. The gallery is automatically opened when a new custom widget is added to a page. Descriptions, authors, and update times are pulled from the widget manifest. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D4309
This commit is contained in:
@@ -42,6 +42,7 @@ import {getFilterFunc, QuerySetManager} from 'app/client/models/QuerySet';
|
||||
import TableModel from 'app/client/models/TableModel';
|
||||
import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
|
||||
import {App} from 'app/client/ui/App';
|
||||
import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
|
||||
import {DocHistory} from 'app/client/ui/DocHistory';
|
||||
import {startDocTour} from "app/client/ui/DocTour";
|
||||
import {DocTutorial} from 'app/client/ui/DocTutorial';
|
||||
@@ -138,6 +139,13 @@ interface PopupSectionOptions {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
interface AddSectionOptions {
|
||||
/** If focus should move to the new section. Defaults to `true`. */
|
||||
focus?: boolean;
|
||||
/** If popups should be shown (e.g. Card Layout tip). Defaults to `true`. */
|
||||
popups?: boolean;
|
||||
}
|
||||
|
||||
export class GristDoc extends DisposableWithEvents {
|
||||
public docModel: DocModel;
|
||||
public viewModel: ViewRec;
|
||||
@@ -894,38 +902,27 @@ export class GristDoc extends DisposableWithEvents {
|
||||
/**
|
||||
* Adds a view section described by val to the current page.
|
||||
*/
|
||||
public async addWidgetToPage(val: IPageWidget) {
|
||||
const docData = this.docModel.docData;
|
||||
const viewName = this.viewModel.name.peek();
|
||||
public async addWidgetToPage(widget: IPageWidget) {
|
||||
const {table, type} = widget;
|
||||
let tableId: string | null | undefined;
|
||||
if (val.table === 'New Table') {
|
||||
if (table === 'New Table') {
|
||||
tableId = await this._promptForName();
|
||||
if (tableId === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const widgetType = getTelemetryWidgetTypeFromPageWidget(val);
|
||||
logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}});
|
||||
if (val.link !== NoLink) {
|
||||
logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
|
||||
if (type === 'custom') {
|
||||
return showCustomWidgetGallery(this, {
|
||||
addWidget: () => this._addWidgetToPage(widget, tableId),
|
||||
});
|
||||
}
|
||||
|
||||
const res: {sectionRef: number} = await docData.bundleActions(
|
||||
const viewName = this.viewModel.name.peek();
|
||||
const {sectionRef} = await this.docData.bundleActions(
|
||||
t("Added new linked section to view {{viewName}}", {viewName}),
|
||||
() => this.addWidgetToPageImpl(val, tableId ?? null)
|
||||
() => this._addWidgetToPage(widget, tableId ?? null)
|
||||
);
|
||||
|
||||
// The newly-added section should be given focus.
|
||||
this.viewModel.activeSectionId(res.sectionRef);
|
||||
|
||||
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
|
||||
|
||||
if (AttachedCustomWidgets.guard(val.type)) {
|
||||
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
|
||||
}
|
||||
|
||||
return res.sectionRef;
|
||||
return sectionRef;
|
||||
}
|
||||
|
||||
public async onCreateForm() {
|
||||
@@ -941,80 +938,31 @@ export class GristDoc extends DisposableWithEvents {
|
||||
commands.allCommands.expandSection.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual implementation of addWidgetToPage
|
||||
*/
|
||||
public async addWidgetToPageImpl(val: IPageWidget, tableId: string | null = null) {
|
||||
const viewRef = this.activeViewId.get();
|
||||
const tableRef = val.table === 'New Table' ? 0 : val.table;
|
||||
const result = await this.docData.sendAction(
|
||||
['CreateViewSection', tableRef, viewRef, val.type, val.summarize ? val.columns : null, tableId]
|
||||
);
|
||||
if (val.type === 'chart') {
|
||||
await this._ensureOneNumericSeries(result.sectionRef);
|
||||
}
|
||||
if (val.type === 'form') {
|
||||
await this._setDefaultFormLayoutSpec(result.sectionRef);
|
||||
}
|
||||
await this.saveLink(val.link, result.sectionRef);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`.
|
||||
*/
|
||||
public async addNewPage(val: IPageWidget) {
|
||||
logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}});
|
||||
logTelemetryEvent('addedWidget', {
|
||||
full: {
|
||||
docIdDigest: this.docId(),
|
||||
widgetType: getTelemetryWidgetTypeFromPageWidget(val),
|
||||
},
|
||||
});
|
||||
|
||||
let viewRef: IDocPage;
|
||||
let sectionRef: number | undefined;
|
||||
await this.docData.bundleActions('Add new page', async () => {
|
||||
if (val.table === 'New Table') {
|
||||
const name = await this._promptForName();
|
||||
if (name === undefined) {
|
||||
return;
|
||||
}
|
||||
if (val.type === WidgetType.Table) {
|
||||
const result = await this.docData.sendAction(['AddEmptyTable', name]);
|
||||
viewRef = result.views[0].id;
|
||||
} else {
|
||||
// This will create a new table and page.
|
||||
const result = await this.docData.sendAction(
|
||||
['CreateViewSection', /* new table */0, 0, val.type, null, name]
|
||||
);
|
||||
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
|
||||
}
|
||||
} else {
|
||||
const result = await this.docData.sendAction(
|
||||
['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null, null]
|
||||
);
|
||||
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
|
||||
if (val.type === 'chart') {
|
||||
await this._ensureOneNumericSeries(sectionRef!);
|
||||
}
|
||||
}
|
||||
if (val.type === 'form') {
|
||||
await this._setDefaultFormLayoutSpec(sectionRef!);
|
||||
}
|
||||
});
|
||||
|
||||
await this.openDocPage(viewRef!);
|
||||
if (sectionRef) {
|
||||
// The newly-added section should be given focus.
|
||||
this.viewModel.activeSectionId(sectionRef);
|
||||
const {table, type} = val;
|
||||
let tableId: string | null | undefined;
|
||||
if (table === 'New Table') {
|
||||
tableId = await this._promptForName();
|
||||
if (tableId === undefined) { return; }
|
||||
}
|
||||
if (type === 'custom') {
|
||||
return showCustomWidgetGallery(this, {
|
||||
addWidget: () => this._addPage(val, tableId ?? null) as Promise<{
|
||||
viewRef: number;
|
||||
sectionRef: number;
|
||||
}>,
|
||||
});
|
||||
}
|
||||
|
||||
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
|
||||
|
||||
if (AttachedCustomWidgets.guard(val.type)) {
|
||||
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
|
||||
}
|
||||
const {sectionRef, viewRef} = await this.docData.bundleActions(
|
||||
'Add new page',
|
||||
() => this._addPage(val, tableId ?? null)
|
||||
);
|
||||
await this._focus({sectionRef, viewRef});
|
||||
this._showNewWidgetPopups(type);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1460,6 +1408,90 @@ export class GristDoc extends DisposableWithEvents {
|
||||
return values;
|
||||
}
|
||||
|
||||
private async _addWidgetToPage(
|
||||
widget: IPageWidget,
|
||||
tableId: string | null = null,
|
||||
{focus = true, popups = true}: AddSectionOptions= {}
|
||||
) {
|
||||
const {columns, link, summarize, table, type} = widget;
|
||||
const viewRef = this.activeViewId.get();
|
||||
const tableRef = table === 'New Table' ? 0 : table;
|
||||
const result: {viewRef: number, sectionRef: number} = await this.docData.sendAction(
|
||||
['CreateViewSection', tableRef, viewRef, type, summarize ? columns : null, tableId]
|
||||
);
|
||||
if (type === 'chart') {
|
||||
await this._ensureOneNumericSeries(result.sectionRef);
|
||||
}
|
||||
if (type === 'form') {
|
||||
await this._setDefaultFormLayoutSpec(result.sectionRef);
|
||||
}
|
||||
await this.saveLink(link, result.sectionRef);
|
||||
const widgetType = getTelemetryWidgetTypeFromPageWidget(widget);
|
||||
logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}});
|
||||
if (link !== NoLink) {
|
||||
logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
|
||||
}
|
||||
if (focus) { await this._focus({sectionRef: result.sectionRef}); }
|
||||
if (popups) { this._showNewWidgetPopups(type); }
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _addPage(
|
||||
widget: IPageWidget,
|
||||
tableId: string | null = null,
|
||||
{focus = true, popups = true}: AddSectionOptions = {}
|
||||
) {
|
||||
const {columns, summarize, table, type} = widget;
|
||||
let viewRef: number;
|
||||
let sectionRef: number | undefined;
|
||||
if (table === 'New Table') {
|
||||
if (type === WidgetType.Table) {
|
||||
const result = await this.docData.sendAction(['AddEmptyTable', tableId]);
|
||||
viewRef = result.views[0].id;
|
||||
} else {
|
||||
// This will create a new table and page.
|
||||
const result = await this.docData.sendAction(
|
||||
['CreateViewSection', 0, 0, type, null, tableId]
|
||||
);
|
||||
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
|
||||
}
|
||||
} else {
|
||||
const result = await this.docData.sendAction(
|
||||
['CreateViewSection', table, 0, type, summarize ? columns : null, null]
|
||||
);
|
||||
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
|
||||
if (type === 'chart') {
|
||||
await this._ensureOneNumericSeries(sectionRef!);
|
||||
}
|
||||
}
|
||||
if (type === 'form') {
|
||||
await this._setDefaultFormLayoutSpec(sectionRef!);
|
||||
}
|
||||
logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}});
|
||||
logTelemetryEvent('addedWidget', {
|
||||
full: {
|
||||
docIdDigest: this.docId(),
|
||||
widgetType: getTelemetryWidgetTypeFromPageWidget(widget),
|
||||
},
|
||||
});
|
||||
if (focus) { await this._focus({viewRef, sectionRef}); }
|
||||
if (popups) { this._showNewWidgetPopups(type); }
|
||||
return {viewRef, sectionRef};
|
||||
}
|
||||
|
||||
private async _focus({viewRef, sectionRef}: {viewRef?: number, sectionRef?: number}) {
|
||||
if (viewRef) { await this.openDocPage(viewRef); }
|
||||
if (sectionRef) { this.viewModel.activeSectionId(sectionRef); }
|
||||
}
|
||||
|
||||
private _showNewWidgetPopups(type: IWidgetType) {
|
||||
this._maybeShowEditCardLayoutTip(type).catch(reportError);
|
||||
|
||||
if (AttachedCustomWidgets.guard(type)) {
|
||||
this._handleNewAttachedCustomWidget(type).catch(reportError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens popup with a section data (used by Raw Data view).
|
||||
*/
|
||||
@@ -1718,7 +1750,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
const sectionId = section.id();
|
||||
|
||||
// create a new section
|
||||
const sectionCreationResult = await this.addWidgetToPageImpl(newVal);
|
||||
const sectionCreationResult = await this._addWidgetToPage(newVal, null, {focus: false, popups: false});
|
||||
|
||||
// update section name
|
||||
const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef);
|
||||
|
||||
@@ -223,10 +223,15 @@ export class WidgetFrame extends DisposableWithEvents {
|
||||
|
||||
// Appends access level to query string.
|
||||
private _urlWithAccess(url: string) {
|
||||
if (!url) {
|
||||
if (!url) { return url; }
|
||||
|
||||
let urlObj: URL;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return url;
|
||||
}
|
||||
const urlObj = new URL(url);
|
||||
urlObj.searchParams.append('access', this._options.access);
|
||||
urlObj.searchParams.append('readonly', String(this._options.readonly));
|
||||
// Append user and document preferences to query string.
|
||||
|
||||
@@ -134,6 +134,10 @@ div:hover > .kf_tooltip {
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
.kf_prompt_content:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.kf_draggable {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -62,8 +62,6 @@ export interface TopAppModel {
|
||||
orgs: Observable<Organization[]>;
|
||||
users: Observable<FullUser[]>;
|
||||
|
||||
customWidgets: Observable<ICustomWidget[]|null>;
|
||||
|
||||
// Reinitialize the app. This is called when org or user changes.
|
||||
initialize(): void;
|
||||
|
||||
@@ -162,26 +160,26 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
||||
public readonly orgs = Observable.create<Organization[]>(this, []);
|
||||
public readonly users = Observable.create<FullUser[]>(this, []);
|
||||
public readonly plugins: LocalPlugin[] = [];
|
||||
public readonly customWidgets = Observable.create<ICustomWidget[]|null>(this, null);
|
||||
private readonly _gristConfig?: GristLoadConfig;
|
||||
private readonly _gristConfig? = this._window.gristConfig;
|
||||
// Keep a list of available widgets, once requested, so we don't have to
|
||||
// keep reloading it. Downside: browser page will need reloading to pick
|
||||
// up new widgets - that seems ok.
|
||||
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
|
||||
|
||||
constructor(window: {gristConfig?: GristLoadConfig},
|
||||
constructor(private _window: {gristConfig?: GristLoadConfig},
|
||||
public readonly api: UserAPI = newUserAPIImpl(),
|
||||
public readonly options: TopAppModelOptions = {}
|
||||
) {
|
||||
super();
|
||||
setErrorNotifier(this.notifier);
|
||||
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
|
||||
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
|
||||
this._gristConfig = window.gristConfig;
|
||||
this.isSingleOrg = Boolean(this._gristConfig?.singleOrg);
|
||||
this.productFlavor = getFlavor(this._gristConfig?.org);
|
||||
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
|
||||
const widgets = this.options.useApi === false ? [] : await this.api.getWidgets();
|
||||
this.customWidgets.set(widgets);
|
||||
return widgets;
|
||||
if (this.options.useApi === false || !this._gristConfig?.enableWidgetRepository) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await this.api.getWidgets();
|
||||
});
|
||||
|
||||
// Initially, and on any change to subdomain, call initialize() to get the full Organization
|
||||
@@ -214,8 +212,7 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
||||
public async testReloadWidgets() {
|
||||
console.log("testReloadWidgets");
|
||||
this._widgets.clear();
|
||||
this.customWidgets.set(null);
|
||||
console.log("testReloadWidgets cleared and nulled");
|
||||
console.log("testReloadWidgets cleared");
|
||||
const result = await this.getWidgets();
|
||||
console.log("testReloadWidgets got", {result});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {makeTestId} from 'app/client/lib/domUtils';
|
||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import * as kf from 'app/client/lib/koForm';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
||||
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
|
||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {
|
||||
cssDeveloperLink,
|
||||
cssWidgetMetadata,
|
||||
cssWidgetMetadataName,
|
||||
cssWidgetMetadataRow,
|
||||
cssWidgetMetadataValue,
|
||||
CUSTOM_URL_WIDGET_ID,
|
||||
getWidgetName,
|
||||
showCustomWidgetGallery,
|
||||
} from 'app/client/ui/CustomWidgetGallery';
|
||||
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';
|
||||
@@ -14,16 +25,15 @@ 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,
|
||||
DomContents,
|
||||
fromKo,
|
||||
MultiHolder,
|
||||
Observable,
|
||||
@@ -33,22 +43,8 @@ import {
|
||||
|
||||
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>,
|
||||
@@ -319,17 +315,17 @@ class ColumnListPicker extends Disposable {
|
||||
}
|
||||
|
||||
class CustomSectionConfigurationConfig extends Disposable{
|
||||
// Does widget has custom configuration.
|
||||
private readonly _hasConfiguration: Computed<boolean>;
|
||||
private readonly _hasConfiguration = Computed.create(this, use =>
|
||||
Boolean(use(this._section.hasCustomOptions) || use(this._section.columnsToMap)));
|
||||
|
||||
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, () =>
|
||||
return dom.maybe(this._hasConfiguration, () => [
|
||||
cssSeparator(),
|
||||
dom.maybe(this._section.hasCustomOptions, () =>
|
||||
cssSection(
|
||||
textButton(
|
||||
t("Open configuration"),
|
||||
@@ -363,7 +359,7 @@ class CustomSectionConfigurationConfig extends Disposable{
|
||||
: dom.create(ColumnPicker, m.value, m.column, this._section)),
|
||||
);
|
||||
})
|
||||
);
|
||||
]);
|
||||
}
|
||||
private _openConfiguration(): void {
|
||||
allCommands.openWidgetConfiguration.run();
|
||||
@@ -384,274 +380,107 @@ class CustomSectionConfigurationConfig extends Disposable{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom widget configuration.
|
||||
*
|
||||
* Allows picking a custom widget from a gallery of available widgets
|
||||
* (fetched from the `/widgets` endpoint), which includes the Custom URL
|
||||
* widget.
|
||||
*
|
||||
* When a custom widget has a desired `accessLevel` set to a value other
|
||||
* than `"None"`, a prompt will be shown to grant the requested access level
|
||||
* to the widget.
|
||||
*
|
||||
* When `gristConfig.enableWidgetRepository` is set to false, only the
|
||||
* Custom URL widget will be available to select in the gallery.
|
||||
*/
|
||||
export class CustomSectionConfig extends Disposable {
|
||||
protected _customSectionConfigurationConfig = new CustomSectionConfigurationConfig(
|
||||
this._section, this._gristDoc);
|
||||
|
||||
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>;
|
||||
private readonly _widgetId = Computed.create(this, use => {
|
||||
// Stored in one of two places, depending on age of document.
|
||||
const widgetId = use(this._section.customDef.widgetId) ||
|
||||
use(this._section.customDef.widgetDef)?.widgetId;
|
||||
if (widgetId) {
|
||||
const pluginId = use(this._section.customDef.pluginId);
|
||||
return (pluginId || '') + ':' + widgetId;
|
||||
} else {
|
||||
return CUSTOM_URL_WIDGET_ID;
|
||||
}
|
||||
});
|
||||
|
||||
private readonly _isCustomUrlWidget = Computed.create(this, this._widgetId, (_use, widgetId) => {
|
||||
return widgetId === CUSTOM_URL_WIDGET_ID;
|
||||
});
|
||||
|
||||
private readonly _currentAccess = Computed.create(this, use =>
|
||||
(use(this._section.customDef.access) as AccessLevel) || AccessLevel.none)
|
||||
.onWrite(async newAccess => {
|
||||
await this._section.customDef.access.setAndSave(newAccess);
|
||||
});
|
||||
|
||||
private readonly _desiredAccess = fromKo(this._section.desiredAccessLevel);
|
||||
|
||||
private readonly _url = Computed.create(this, use => use(this._section.customDef.url) || '')
|
||||
.onWrite(async newUrl => {
|
||||
bundleChanges(() => {
|
||||
this._section.customDef.renderAfterReady(false);
|
||||
if (newUrl) {
|
||||
this._section.customDef.widgetId(null);
|
||||
this._section.customDef.pluginId('');
|
||||
this._section.customDef.widgetDef(null);
|
||||
}
|
||||
this._section.customDef.url(newUrl);
|
||||
});
|
||||
await this._section.saveCustomDef();
|
||||
});
|
||||
|
||||
private readonly _requiresAccess = Computed.create(this, use => {
|
||||
const [currentAccess, desiredAccess] = [use(this._currentAccess), use(this._desiredAccess)];
|
||||
return desiredAccess && !isSatisfied(currentAccess, desiredAccess);
|
||||
});
|
||||
|
||||
private readonly _widgetDetailsExpanded: Observable<boolean>;
|
||||
|
||||
private readonly _widgets: Observable<ICustomWidget[] | null> = Observable.create(this, null);
|
||||
|
||||
private readonly _selectedWidget = Computed.create(this, use => {
|
||||
const id = use(this._widgetId);
|
||||
if (id === CUSTOM_URL_WIDGET_ID) { return null; }
|
||||
|
||||
const widgets = use(this._widgets);
|
||||
if (!widgets) { return null; }
|
||||
|
||||
const [pluginId, widgetId] = id.split(':');
|
||||
return matchWidget(widgets, {pluginId, widgetId}) ?? null;
|
||||
});
|
||||
|
||||
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;
|
||||
const userId = this._gristDoc.appModel.currentUser?.id ?? 0;
|
||||
this._widgetDetailsExpanded = this.autoDispose(localStorageBoolObs(
|
||||
`u:${userId};customWidgetDetailsExpanded`,
|
||||
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.
|
||||
this._getWidgets()
|
||||
.then(widgets => {
|
||||
if (this.isDisposed()) { return; }
|
||||
|
||||
// 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);
|
||||
this._widgets.set(widgets);
|
||||
})
|
||||
.catch(reportError);
|
||||
|
||||
// Clear intermediate state when section changes.
|
||||
this.autoDispose(_section.id.subscribe(() => this._reject()));
|
||||
this.autoDispose(_section.id.subscribe(() => this._dismissAccessPrompt()));
|
||||
}
|
||||
|
||||
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) || [])
|
||||
.filter(w => w?.published !== false)
|
||||
.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.attachPopup('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(),
|
||||
public buildDom(): DomContents {
|
||||
return dom('div',
|
||||
this._buildWidgetSelector(),
|
||||
this._buildAccessLevelConfig(),
|
||||
this._customSectionConfigurationConfig.buildDom(),
|
||||
);
|
||||
}
|
||||
@@ -661,21 +490,194 @@ export class CustomSectionConfig extends Disposable {
|
||||
}
|
||||
|
||||
protected async _getWidgets() {
|
||||
await this._gristDoc.app.topAppModel.getWidgets();
|
||||
return await this._gristDoc.app.topAppModel.getWidgets();
|
||||
}
|
||||
|
||||
private _accept() {
|
||||
private _buildWidgetSelector() {
|
||||
if (!this.shouldRenderWidgetSelector()) { return null; }
|
||||
|
||||
return [
|
||||
cssRow(
|
||||
cssWidgetSelector(
|
||||
this._buildShowWidgetDetailsButton(),
|
||||
this._buildWidgetName(),
|
||||
),
|
||||
),
|
||||
this._maybeBuildWidgetDetails(),
|
||||
];
|
||||
}
|
||||
|
||||
private _buildShowWidgetDetailsButton() {
|
||||
return cssShowWidgetDetails(
|
||||
cssShowWidgetDetailsIcon(
|
||||
'Dropdown',
|
||||
cssShowWidgetDetailsIcon.cls('-collapsed', use => !use(this._widgetDetailsExpanded)),
|
||||
testId('toggle-custom-widget-details'),
|
||||
testId(use => !use(this._widgetDetailsExpanded)
|
||||
? 'show-custom-widget-details'
|
||||
: 'hide-custom-widget-details'
|
||||
),
|
||||
),
|
||||
cssWidgetLabel(t('Widget')),
|
||||
dom.on('click', () => {
|
||||
this._widgetDetailsExpanded.set(!this._widgetDetailsExpanded.get());
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildWidgetName() {
|
||||
return cssWidgetName(
|
||||
dom.text(use => {
|
||||
if (use(this._isCustomUrlWidget)) {
|
||||
return t('Custom URL');
|
||||
} else {
|
||||
const widget = use(this._selectedWidget) ?? use(this._section.customDef.widgetDef);
|
||||
return widget ? getWidgetName(widget) : use(this._widgetId);
|
||||
}
|
||||
}),
|
||||
dom.on('click', () => showCustomWidgetGallery(this._gristDoc, {
|
||||
sectionRef: this._section.id(),
|
||||
})),
|
||||
testId('open-custom-widget-gallery'),
|
||||
);
|
||||
}
|
||||
|
||||
private _maybeBuildWidgetDetails() {
|
||||
return dom.maybe(this._widgetDetailsExpanded, () =>
|
||||
dom.domComputed(this._selectedWidget, (widget) =>
|
||||
cssRow(
|
||||
this._buildWidgetDetails(widget),
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _buildWidgetDetails(widget: ICustomWidget | null) {
|
||||
return dom.domComputed(this._isCustomUrlWidget, (isCustomUrlWidget) => {
|
||||
if (isCustomUrlWidget) {
|
||||
return cssCustomUrlDetails(
|
||||
cssTextInput(
|
||||
this._url,
|
||||
async value => this._url.set(value),
|
||||
dom.show(this._isCustomUrlWidget),
|
||||
{placeholder: t('Enter Custom URL')},
|
||||
),
|
||||
);
|
||||
} else if (!widget?.description && !widget?.authors?.[0] && !widget?.lastUpdatedAt) {
|
||||
return cssDetailsMessage(t('Missing description and author information.'));
|
||||
} else {
|
||||
return cssWidgetDetails(
|
||||
!widget?.description ? null : cssWidgetDescription(
|
||||
widget.description,
|
||||
testId('custom-widget-description'),
|
||||
),
|
||||
cssWidgetMetadata(
|
||||
!widget?.authors?.[0] ? null : cssWidgetMetadataRow(
|
||||
cssWidgetMetadataName(t('Developer:')),
|
||||
cssWidgetMetadataValue(
|
||||
widget.authors[0].url
|
||||
? cssDeveloperLink(
|
||||
widget.authors[0].name,
|
||||
{href: widget.authors[0].url, target: '_blank'},
|
||||
testId('custom-widget-developer'),
|
||||
)
|
||||
: dom('span',
|
||||
widget.authors[0].name,
|
||||
testId('custom-widget-developer'),
|
||||
),
|
||||
testId('custom-widget-developer'),
|
||||
),
|
||||
),
|
||||
!widget?.lastUpdatedAt ? null : cssWidgetMetadataRow(
|
||||
cssWidgetMetadataName(t('Last updated:')),
|
||||
cssWidgetMetadataValue(
|
||||
new Date(widget.lastUpdatedAt).toLocaleDateString('default', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}),
|
||||
testId('custom-widget-last-updated'),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _buildAccessLevelConfig() {
|
||||
return [
|
||||
cssSeparator({style: 'margin-top: 0px'}),
|
||||
cssLabel(t('ACCESS LEVEL')),
|
||||
cssRow(select(this._currentAccess, getAccessLevels()), testId('access')),
|
||||
dom.maybeOwned(this._requiresAccess, (owner) => kf.prompt(
|
||||
(elem: HTMLDivElement) => { FocusLayer.create(owner, {defaultFocusElem: elem, pauseMousetrap: true}); },
|
||||
cssColumns(
|
||||
cssWarningWrapper(icon('Lock')),
|
||||
dom('div',
|
||||
cssConfirmRow(
|
||||
dom.domComputed(this._desiredAccess, (level) => this._buildAccessLevelPrompt(level))
|
||||
),
|
||||
cssConfirmRow(
|
||||
primaryButton(
|
||||
t('Accept'),
|
||||
testId('access-accept'),
|
||||
dom.on('click', () => this._grantDesiredAccess())
|
||||
),
|
||||
basicButton(
|
||||
t('Reject'),
|
||||
testId('access-reject'),
|
||||
dom.on('click', () => this._dismissAccessPrompt())
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
dom.onKeyDown({
|
||||
Enter: () => this._grantDesiredAccess(),
|
||||
Escape:() => this._dismissAccessPrompt(),
|
||||
}),
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
private _buildAccessLevelPrompt(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")
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _grantDesiredAccess() {
|
||||
if (this._desiredAccess.get()) {
|
||||
this._currentAccess.set(this._desiredAccess.get()!);
|
||||
}
|
||||
this._reject();
|
||||
this._dismissAccessPrompt();
|
||||
}
|
||||
|
||||
private _reject() {
|
||||
private _dismissAccessPrompt() {
|
||||
this._desiredAccess.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
function getAccessLevels(): IOptionFull<string>[] {
|
||||
return [
|
||||
{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},
|
||||
];
|
||||
}
|
||||
|
||||
const cssWarningWrapper = styled('div', `
|
||||
padding-left: 8px;
|
||||
padding-top: 6px;
|
||||
@@ -700,12 +702,6 @@ 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;
|
||||
`);
|
||||
@@ -748,17 +744,9 @@ const cssAddMapping = styled('div', `
|
||||
`);
|
||||
|
||||
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};
|
||||
}
|
||||
@@ -771,3 +759,62 @@ const cssDisabledSelect = styled(select, `
|
||||
const cssBlank = styled(cssOptionLabel, `
|
||||
--grist-option-label-color: ${theme.lightText};
|
||||
`);
|
||||
|
||||
const cssWidgetSelector = styled('div', `
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
column-gap: 16px;
|
||||
`);
|
||||
|
||||
const cssShowWidgetDetails = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 4px;
|
||||
cursor: pointer;
|
||||
`);
|
||||
|
||||
const cssShowWidgetDetailsIcon = styled(icon, `
|
||||
--icon-color: ${theme.lightText};
|
||||
flex-shrink: 0;
|
||||
|
||||
&-collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
`);
|
||||
|
||||
const cssWidgetLabel = styled('div', `
|
||||
text-transform: uppercase;
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
`);
|
||||
|
||||
const cssWidgetName = styled('div', `
|
||||
color: ${theme.rightPanelCustomWidgetButtonFg};
|
||||
background-color: ${theme.rightPanelCustomWidgetButtonBg};
|
||||
height: 24px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`);
|
||||
|
||||
const cssWidgetDetails = styled('div', `
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 8px;
|
||||
`);
|
||||
|
||||
const cssCustomUrlDetails = styled(cssWidgetDetails, `
|
||||
flex: 1 0 auto;
|
||||
`);
|
||||
|
||||
const cssDetailsMessage = styled('div', `
|
||||
color: ${theme.lightText};
|
||||
`);
|
||||
|
||||
const cssWidgetDescription = styled('div', `
|
||||
margin-bottom: 16px;
|
||||
`);
|
||||
|
||||
661
app/client/ui/CustomWidgetGallery.ts
Normal file
661
app/client/ui/CustomWidgetGallery.ts
Normal file
@@ -0,0 +1,661 @@
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {textInput} from 'app/client/ui/inputs';
|
||||
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||
import {withInfoTooltip} from 'app/client/ui/tooltips';
|
||||
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {IModalControl, modal} from 'app/client/ui2018/modals';
|
||||
import {AccessLevel, ICustomWidget, matchWidget, WidgetAuthor} from 'app/common/CustomWidget';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {bundleChanges, Computed, Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
|
||||
import escapeRegExp from 'lodash/escapeRegExp';
|
||||
|
||||
const testId = makeTestId('test-custom-widget-gallery-');
|
||||
|
||||
const t = makeT('CustomWidgetGallery');
|
||||
|
||||
export const CUSTOM_URL_WIDGET_ID = 'custom';
|
||||
|
||||
interface Options {
|
||||
sectionRef?: number;
|
||||
addWidget?(): Promise<{viewRef: number, sectionRef: number}>;
|
||||
}
|
||||
|
||||
export function showCustomWidgetGallery(gristDoc: GristDoc, options: Options = {}) {
|
||||
modal((ctl) => [
|
||||
dom.create(CustomWidgetGallery, ctl, gristDoc, options),
|
||||
cssModal.cls(''),
|
||||
]);
|
||||
}
|
||||
|
||||
interface WidgetInfo {
|
||||
variant: WidgetVariant;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
developer?: WidgetAuthor;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
interface CustomWidgetACItem extends ICustomWidget {
|
||||
cleanText: string;
|
||||
}
|
||||
|
||||
type WidgetVariant = 'custom' | 'grist' | 'community';
|
||||
|
||||
class CustomWidgetGallery extends Disposable {
|
||||
private readonly _customUrl: Observable<string>;
|
||||
private readonly _filteredWidgets = Observable.create<ICustomWidget[] | null>(this, null);
|
||||
private readonly _section: ViewSectionRec | null = null;
|
||||
private readonly _searchText = Observable.create(this, '');
|
||||
private readonly _saveDisabled: Computed<boolean>;
|
||||
private readonly _savedWidgetId: Computed<string | null>;
|
||||
private readonly _selectedWidgetId = Observable.create<string | null>(this, null);
|
||||
private readonly _widgets = Observable.create<CustomWidgetACItem[] | null>(this, null);
|
||||
|
||||
constructor(
|
||||
private _ctl: IModalControl,
|
||||
private _gristDoc: GristDoc,
|
||||
private _options: Options = {}
|
||||
) {
|
||||
super();
|
||||
|
||||
const {sectionRef} = _options;
|
||||
if (sectionRef) {
|
||||
const section = this._gristDoc.docModel.viewSections.getRowModel(sectionRef);
|
||||
if (!section.id.peek()) {
|
||||
throw new Error(`Section ${sectionRef} does not exist`);
|
||||
}
|
||||
|
||||
this._section = section;
|
||||
this.autoDispose(section._isDeleted.subscribe((isDeleted) => {
|
||||
if (isDeleted) { this._ctl.close(); }
|
||||
}));
|
||||
}
|
||||
|
||||
let customUrl = '';
|
||||
if (this._section) {
|
||||
customUrl = this._section.customDef.url() ?? '';
|
||||
}
|
||||
this._customUrl = Observable.create(this, customUrl);
|
||||
|
||||
this._savedWidgetId = Computed.create(this, (use) => {
|
||||
if (!this._section) { return null; }
|
||||
|
||||
const {customDef} = this._section;
|
||||
// May be stored in one of two places, depending on age of document.
|
||||
const widgetId = use(customDef.widgetId) || use(customDef.widgetDef)?.widgetId;
|
||||
if (widgetId) {
|
||||
const pluginId = use(customDef.pluginId);
|
||||
const widget = matchWidget(use(this._widgets) ?? [], {
|
||||
widgetId,
|
||||
pluginId,
|
||||
});
|
||||
return widget ? `${pluginId}:${widgetId}` : null;
|
||||
} else {
|
||||
return CUSTOM_URL_WIDGET_ID;
|
||||
}
|
||||
});
|
||||
|
||||
this._saveDisabled = Computed.create(this, use => {
|
||||
const selectedWidgetId = use(this._selectedWidgetId);
|
||||
if (!selectedWidgetId) { return true; }
|
||||
if (!this._section) { return false; }
|
||||
|
||||
const savedWidgetId = use(this._savedWidgetId);
|
||||
if (selectedWidgetId === CUSTOM_URL_WIDGET_ID) {
|
||||
return (
|
||||
use(this._savedWidgetId) === CUSTOM_URL_WIDGET_ID &&
|
||||
use(this._customUrl) === use(this._section.customDef.url)
|
||||
);
|
||||
} else {
|
||||
return selectedWidgetId === savedWidgetId;
|
||||
}
|
||||
});
|
||||
|
||||
this._initializeWidgets().catch(reportError);
|
||||
|
||||
this.autoDispose(this._searchText.addListener(() => {
|
||||
this._filterWidgets();
|
||||
this._selectedWidgetId.set(null);
|
||||
}));
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return cssCustomWidgetGallery(
|
||||
cssHeader(
|
||||
cssTitle(t('Choose Custom Widget')),
|
||||
cssSearchInputWrapper(
|
||||
cssSearchIcon('Search'),
|
||||
cssSearchInput(
|
||||
this._searchText,
|
||||
{placeholder: t('Search')},
|
||||
(el) => { setTimeout(() => el.focus(), 10); },
|
||||
testId('search'),
|
||||
),
|
||||
),
|
||||
),
|
||||
shadowScroll(
|
||||
this._buildWidgets(),
|
||||
cssShadowScroll.cls(''),
|
||||
),
|
||||
cssFooter(
|
||||
dom('div',
|
||||
cssHelpLink(
|
||||
{href: commonUrls.helpCustomWidgets, target: '_blank'},
|
||||
cssHelpIcon('Question'),
|
||||
t('Learn more about Custom Widgets'),
|
||||
),
|
||||
),
|
||||
cssFooterButtons(
|
||||
bigBasicButton(
|
||||
t('Cancel'),
|
||||
dom.on('click', () => this._ctl.close()),
|
||||
testId('cancel'),
|
||||
),
|
||||
bigPrimaryButton(
|
||||
this._options.addWidget ? t('Add Widget') : t('Change Widget'),
|
||||
dom.on('click', () => this._save()),
|
||||
dom.boolAttr('disabled', this._saveDisabled),
|
||||
testId('save'),
|
||||
),
|
||||
),
|
||||
),
|
||||
dom.onKeyDown({
|
||||
Enter: () => this._save(),
|
||||
Escape: () => this._deselectOrClose(),
|
||||
}),
|
||||
dom.on('click', (ev) => this._maybeClearSelection(ev)),
|
||||
testId('container'),
|
||||
);
|
||||
}
|
||||
|
||||
private async _initializeWidgets() {
|
||||
const widgets: ICustomWidget[] = [
|
||||
{
|
||||
widgetId: 'custom',
|
||||
name: t('Custom URL'),
|
||||
description: t('Add a widget from outside this gallery.'),
|
||||
url: '',
|
||||
},
|
||||
];
|
||||
try {
|
||||
const remoteWidgets = await this._gristDoc.appModel.topAppModel.getWidgets();
|
||||
if (this.isDisposed()) { return; }
|
||||
|
||||
widgets.push(...remoteWidgets
|
||||
.filter(({published}) => published !== false)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
} catch (e) {
|
||||
reportError(e);
|
||||
}
|
||||
|
||||
this._widgets.set(widgets.map(w => ({...w, cleanText: getWidgetCleanText(w)})));
|
||||
this._selectedWidgetId.set(this._savedWidgetId.get());
|
||||
this._filterWidgets();
|
||||
}
|
||||
|
||||
private _filterWidgets() {
|
||||
const widgets = this._widgets.get();
|
||||
if (!widgets) { return; }
|
||||
|
||||
const searchText = this._searchText.get();
|
||||
if (!searchText) {
|
||||
this._filteredWidgets.set(widgets);
|
||||
} else {
|
||||
const searchTerms = searchText.trim().split(/\s+/);
|
||||
const searchPatterns = searchTerms.map(term =>
|
||||
new RegExp(`\\b${escapeRegExp(term)}`, 'i'));
|
||||
const filteredWidgets = widgets.filter(({cleanText}) =>
|
||||
searchPatterns.some(pattern => pattern.test(cleanText))
|
||||
);
|
||||
this._filteredWidgets.set(filteredWidgets);
|
||||
}
|
||||
}
|
||||
|
||||
private _buildWidgets() {
|
||||
return dom.domComputed(this._filteredWidgets, (widgets) => {
|
||||
if (widgets === null) {
|
||||
return cssLoadingSpinner(loadingSpinner());
|
||||
} else if (widgets.length === 0) {
|
||||
return cssNoMatchingWidgets(t('No matching widgets'));
|
||||
} else {
|
||||
return cssWidgets(
|
||||
widgets.map(widget => {
|
||||
const {description, authors = [], lastUpdatedAt} = widget;
|
||||
|
||||
return this._buildWidget({
|
||||
variant: getWidgetVariant(widget),
|
||||
id: getWidgetId(widget),
|
||||
name: getWidgetName(widget),
|
||||
description,
|
||||
developer: authors[0],
|
||||
lastUpdated: lastUpdatedAt,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _buildWidget(info: WidgetInfo) {
|
||||
const {variant, id, name, description, developer, lastUpdated} = info;
|
||||
|
||||
return cssWidget(
|
||||
dom.cls('custom-widget'),
|
||||
cssWidgetHeader(
|
||||
variant === 'custom' ? t('Add Your Own Widget') :
|
||||
variant === 'grist' ? t('Grist Widget') :
|
||||
withInfoTooltip(
|
||||
t('Community Widget'),
|
||||
'communityWidgets',
|
||||
{
|
||||
variant: 'hover',
|
||||
iconDomArgs: [cssTooltipIcon.cls('')],
|
||||
}
|
||||
),
|
||||
cssWidgetHeader.cls('-secondary', ['custom', 'community'].includes(variant)),
|
||||
),
|
||||
cssWidgetBody(
|
||||
cssWidgetName(
|
||||
name,
|
||||
testId('widget-name'),
|
||||
),
|
||||
cssWidgetDescription(
|
||||
description ?? t('(Missing info)'),
|
||||
cssWidgetDescription.cls('-missing', !description),
|
||||
testId('widget-description'),
|
||||
),
|
||||
variant === 'custom' ? null : cssWidgetMetadata(
|
||||
variant === 'grist' ? null : cssWidgetMetadataRow(
|
||||
cssWidgetMetadataName(t('Developer:')),
|
||||
cssWidgetMetadataValue(
|
||||
developer?.url
|
||||
? cssDeveloperLink(
|
||||
developer.name,
|
||||
{href: developer.url, target: '_blank'},
|
||||
dom.on('click', (ev) => ev.stopPropagation()),
|
||||
testId('widget-developer'),
|
||||
)
|
||||
: dom('span',
|
||||
developer?.name ?? t('(Missing info)'),
|
||||
testId('widget-developer'),
|
||||
),
|
||||
cssWidgetMetadataValue.cls('-missing', !developer?.name),
|
||||
testId('widget-developer'),
|
||||
),
|
||||
),
|
||||
cssWidgetMetadataRow(
|
||||
cssWidgetMetadataName(t('Last updated:')),
|
||||
cssWidgetMetadataValue(
|
||||
lastUpdated ?
|
||||
new Date(lastUpdated).toLocaleDateString('default', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
: t('(Missing info)'),
|
||||
cssWidgetMetadataValue.cls('-missing', !lastUpdated),
|
||||
testId('widget-last-updated'),
|
||||
),
|
||||
),
|
||||
testId('widget-metadata'),
|
||||
),
|
||||
variant !== 'custom' ? null : cssCustomUrlInput(
|
||||
this._customUrl,
|
||||
{placeholder: t('Widget URL')},
|
||||
testId('custom-url'),
|
||||
),
|
||||
),
|
||||
cssWidget.cls('-selected', use => id === use(this._selectedWidgetId)),
|
||||
dom.on('click', () => this._selectedWidgetId.set(id)),
|
||||
testId('widget'),
|
||||
testId(`widget-${variant}`),
|
||||
);
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
if (this._saveDisabled.get()) { return; }
|
||||
|
||||
await this._saveSelectedWidget();
|
||||
this._ctl.close();
|
||||
}
|
||||
|
||||
private async _deselectOrClose() {
|
||||
if (this._selectedWidgetId.get()) {
|
||||
this._selectedWidgetId.set(null);
|
||||
} else {
|
||||
this._ctl.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async _saveSelectedWidget() {
|
||||
await this._gristDoc.docData.bundleActions(
|
||||
'Save selected custom widget',
|
||||
async () => {
|
||||
let section = this._section;
|
||||
if (!section) {
|
||||
const {addWidget} = this._options;
|
||||
if (!addWidget) {
|
||||
throw new Error('Cannot add custom widget: missing `addWidget` implementation');
|
||||
}
|
||||
|
||||
const {sectionRef} = await addWidget();
|
||||
const newSection = this._gristDoc.docModel.viewSections.getRowModel(sectionRef);
|
||||
if (!newSection.id.peek()) {
|
||||
throw new Error(`Section ${sectionRef} does not exist`);
|
||||
}
|
||||
section = newSection;
|
||||
}
|
||||
const selectedWidgetId = this._selectedWidgetId.get();
|
||||
if (selectedWidgetId === CUSTOM_URL_WIDGET_ID) {
|
||||
return this._saveCustomUrlWidget(section);
|
||||
} else {
|
||||
return this._saveRemoteWidget(section);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async _saveCustomUrlWidget(section: ViewSectionRec) {
|
||||
bundleChanges(() => {
|
||||
section.customDef.renderAfterReady(false);
|
||||
section.customDef.url(this._customUrl.get());
|
||||
section.customDef.widgetId(null);
|
||||
section.customDef.widgetDef(null);
|
||||
section.customDef.pluginId('');
|
||||
section.customDef.access(AccessLevel.none);
|
||||
section.customDef.widgetOptions(null);
|
||||
section.hasCustomOptions(false);
|
||||
section.customDef.columnsMapping(null);
|
||||
section.columnsToMap(null);
|
||||
section.desiredAccessLevel(AccessLevel.none);
|
||||
});
|
||||
await section.saveCustomDef();
|
||||
}
|
||||
|
||||
private async _saveRemoteWidget(section: ViewSectionRec) {
|
||||
const [pluginId, widgetId] = this._selectedWidgetId.get()!.split(':');
|
||||
const {customDef} = section;
|
||||
if (customDef.pluginId.peek() === pluginId && customDef.widgetId.peek() === widgetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedWidget = matchWidget(this._widgets.get() ?? [], {widgetId, pluginId});
|
||||
if (!selectedWidget) {
|
||||
throw new Error(`Widget ${this._selectedWidgetId.get()} not found`);
|
||||
}
|
||||
|
||||
bundleChanges(() => {
|
||||
section.customDef.renderAfterReady(selectedWidget.renderAfterReady ?? false);
|
||||
section.customDef.access(AccessLevel.none);
|
||||
section.desiredAccessLevel(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);
|
||||
section.customDef.widgetId(selectedWidget.widgetId);
|
||||
section.customDef.pluginId(selectedWidget.source?.pluginId ?? '');
|
||||
section.customDef.url(null);
|
||||
section.customDef.widgetOptions(null);
|
||||
section.hasCustomOptions(false);
|
||||
section.customDef.columnsMapping(null);
|
||||
section.columnsToMap(null);
|
||||
});
|
||||
await section.saveCustomDef();
|
||||
}
|
||||
|
||||
private _maybeClearSelection(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
!target.closest('.custom-widget') &&
|
||||
!target.closest('button') &&
|
||||
!target.closest('a') &&
|
||||
!target.closest('input')
|
||||
) {
|
||||
this._selectedWidgetId.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getWidgetName({name, source}: ICustomWidget) {
|
||||
return source?.name ? `${name} (${source.name})` : name;
|
||||
}
|
||||
|
||||
function getWidgetVariant({isGristLabsMaintained = false, widgetId}: ICustomWidget): WidgetVariant {
|
||||
if (widgetId === CUSTOM_URL_WIDGET_ID) {
|
||||
return 'custom';
|
||||
} else if (isGristLabsMaintained) {
|
||||
return 'grist';
|
||||
} else {
|
||||
return 'community';
|
||||
}
|
||||
}
|
||||
|
||||
function getWidgetId({source, widgetId}: ICustomWidget) {
|
||||
if (widgetId === CUSTOM_URL_WIDGET_ID) {
|
||||
return CUSTOM_URL_WIDGET_ID;
|
||||
} else {
|
||||
return `${source?.pluginId ?? ''}:${widgetId}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getWidgetCleanText({name, description, authors = []}: ICustomWidget) {
|
||||
let cleanText = name;
|
||||
if (description) { cleanText += ` ${description}`; }
|
||||
if (authors[0]) { cleanText += ` ${authors[0].name}`; }
|
||||
return cleanText;
|
||||
}
|
||||
|
||||
export const cssWidgetMetadata = styled('div', `
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 4px;
|
||||
`);
|
||||
|
||||
export const cssWidgetMetadataRow = styled('div', `
|
||||
display: flex;
|
||||
column-gap: 4px;
|
||||
`);
|
||||
|
||||
export const cssWidgetMetadataName = styled('span', `
|
||||
color: ${theme.lightText};
|
||||
font-weight: 600;
|
||||
`);
|
||||
|
||||
export const cssWidgetMetadataValue = styled('div', `
|
||||
&-missing {
|
||||
color: ${theme.lightText};
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssDeveloperLink = styled(cssLink, `
|
||||
font-weight: 600;
|
||||
`);
|
||||
|
||||
const cssCustomWidgetGallery = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
`);
|
||||
|
||||
const WIDGET_WIDTH_PX = 240;
|
||||
|
||||
const WIDGETS_GAP_PX = 16;
|
||||
|
||||
const cssHeader = styled('div', `
|
||||
display: flex;
|
||||
column-gap: 16px;
|
||||
row-gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin: 40px 40px 16px 40px;
|
||||
|
||||
/* Don't go beyond the final grid column. */
|
||||
max-width: ${(3 * WIDGET_WIDTH_PX) + (2 * WIDGETS_GAP_PX)}px;
|
||||
`);
|
||||
|
||||
const cssTitle = styled('div', `
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
line-height: 32px;
|
||||
`);
|
||||
|
||||
const cssSearchInputWrapper = styled('div', `
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`);
|
||||
|
||||
const cssSearchIcon = styled(icon, `
|
||||
margin-left: 8px;
|
||||
position: absolute;
|
||||
--icon-color: ${theme.accentIcon};
|
||||
`);
|
||||
|
||||
const cssSearchInput = styled(textInput, `
|
||||
height: 28px;
|
||||
padding-left: 32px;
|
||||
`);
|
||||
|
||||
const cssShadowScroll = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: unset;
|
||||
flex-grow: 1;
|
||||
padding: 16px 40px;
|
||||
`);
|
||||
|
||||
const cssCenteredFlexGrow = styled('div', `
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`);
|
||||
|
||||
const cssLoadingSpinner = cssCenteredFlexGrow;
|
||||
|
||||
const cssNoMatchingWidgets = styled(cssCenteredFlexGrow, `
|
||||
color: ${theme.lightText};
|
||||
`);
|
||||
|
||||
const cssWidgets = styled('div', `
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(0px, ${WIDGET_WIDTH_PX}px));
|
||||
gap: ${WIDGETS_GAP_PX}px;
|
||||
`);
|
||||
|
||||
const cssWidget = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 1px 1px 4px 1px ${theme.widgetGalleryShadow};
|
||||
border-radius: 4px;
|
||||
min-height: 183.5px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.widgetGalleryBgHover};
|
||||
}
|
||||
&-selected {
|
||||
outline: 2px solid ${theme.widgetGalleryBorderSelected};
|
||||
outline-offset: -2px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssWidgetHeader = styled('div', `
|
||||
flex-shrink: 0;
|
||||
border: 2px solid ${theme.widgetGalleryBorder};
|
||||
border-bottom: 1px solid ${theme.widgetGalleryBorder};
|
||||
border-radius: 4px 4px 0px 0px;
|
||||
color: ${theme.lightText};
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
padding: 4px 18px;
|
||||
text-transform: uppercase;
|
||||
|
||||
&-secondary {
|
||||
border: 0px;
|
||||
color: ${theme.widgetGallerySecondaryHeaderFg};
|
||||
background-color: ${theme.widgetGallerySecondaryHeaderBg};
|
||||
}
|
||||
.${cssWidget.className}:hover &-secondary {
|
||||
background-color: ${theme.widgetGallerySecondaryHeaderBgHover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssWidgetBody = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
border: 2px solid ${theme.widgetGalleryBorder};
|
||||
border-top: 0px;
|
||||
border-radius: 0px 0px 4px 4px;
|
||||
padding: 16px;
|
||||
`);
|
||||
|
||||
const cssWidgetName = styled('div', `
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
`);
|
||||
|
||||
const cssWidgetDescription = styled('div', `
|
||||
margin-bottom: 24px;
|
||||
|
||||
&-missing {
|
||||
color: ${theme.lightText};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssCustomUrlInput = styled(textInput, `
|
||||
height: 28px;
|
||||
`);
|
||||
|
||||
const cssHelpLink = styled(cssLink, `
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
`);
|
||||
|
||||
const cssHelpIcon = styled(icon, `
|
||||
flex-shrink: 0;
|
||||
`);
|
||||
|
||||
const cssFooter = styled('div', `
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 40px;
|
||||
border-top: 1px solid ${theme.widgetGalleryBorder};
|
||||
`);
|
||||
|
||||
const cssFooterButtons = styled('div', `
|
||||
display: flex;
|
||||
column-gap: 8px;
|
||||
`);
|
||||
|
||||
const cssModal = styled('div', `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 930px;
|
||||
max-height: 623px;
|
||||
padding: 0px;
|
||||
`);
|
||||
|
||||
const cssTooltipIcon = styled('div', `
|
||||
color: ${theme.widgetGallerySecondaryHeaderFg};
|
||||
border-color: ${theme.widgetGallerySecondaryHeaderFg};
|
||||
`);
|
||||
@@ -42,7 +42,8 @@ export type Tooltip =
|
||||
| 'formulaColumn'
|
||||
| 'accessRulesTableWide'
|
||||
| 'setChoiceDropdownCondition'
|
||||
| 'setRefDropdownCondition';
|
||||
| 'setRefDropdownCondition'
|
||||
| 'communityWidgets';
|
||||
|
||||
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
|
||||
|
||||
@@ -152,6 +153,15 @@ see or edit which parts of your document.')
|
||||
),
|
||||
...args,
|
||||
),
|
||||
communityWidgets: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div',
|
||||
t('Community widgets are created and maintained by Grist community members.')
|
||||
),
|
||||
dom('div',
|
||||
cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.')),
|
||||
),
|
||||
...args,
|
||||
),
|
||||
};
|
||||
|
||||
export interface BehavioralPromptContent {
|
||||
@@ -307,20 +317,6 @@ to determine who can see or edit which parts of your document.')),
|
||||
forceShow: true,
|
||||
markAsSeen: false,
|
||||
},
|
||||
customURL: {
|
||||
popupType: 'tip',
|
||||
title: () => t('Custom Widgets'),
|
||||
content: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div',
|
||||
t(
|
||||
'You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.'
|
||||
),
|
||||
),
|
||||
dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
|
||||
},
|
||||
calendarConfig: {
|
||||
popupType: 'tip',
|
||||
title: () => t('Calendar'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {GristDoc} from "../components/GristDoc";
|
||||
import {ViewSectionRec} from "../models/entities/ViewSectionRec";
|
||||
import {CustomSectionConfig} from "./CustomSectionConfig";
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
|
||||
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
||||
import {ICustomWidget} from 'app/common/CustomWidget';
|
||||
|
||||
export class PredefinedCustomSectionConfig extends CustomSectionConfig {
|
||||
|
||||
@@ -17,7 +18,7 @@ export class PredefinedCustomSectionConfig extends CustomSectionConfig {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async _getWidgets(): Promise<void> {
|
||||
// Do nothing.
|
||||
protected async _getWidgets(): Promise<ICustomWidget[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
||||
import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
|
||||
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
|
||||
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
|
||||
import {GridOptions} from 'app/client/ui/GridOptions';
|
||||
@@ -526,7 +527,7 @@ export class RightPanel extends Disposable {
|
||||
dom.maybe((use) => use(this._pageWidgetType) === 'custom', () => {
|
||||
const parts = vct._buildCustomTypeItems() as any[];
|
||||
return [
|
||||
cssLabel(t("CUSTOM")),
|
||||
cssSeparator(),
|
||||
// If 'customViewPlugin' feature is on, show the toggle that allows switching to
|
||||
// plugin mode. Note that the default mode for a new 'custom' view is 'url', so that's
|
||||
// the only one that will be shown without the feature flag.
|
||||
@@ -880,13 +881,20 @@ export class RightPanel extends Disposable {
|
||||
|
||||
private _createPageWidgetPicker(): DomElementMethod {
|
||||
const gristDoc = this._gristDoc;
|
||||
const section = gristDoc.viewModel.activeSection;
|
||||
const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val);
|
||||
return (elem) => { attachPageWidgetPicker(elem, gristDoc, onSave, {
|
||||
buttonLabel: t("Save"),
|
||||
value: () => toPageWidget(section.peek()),
|
||||
selectBy: (val) => gristDoc.selectBy(val),
|
||||
}); };
|
||||
const {activeSection} = gristDoc.viewModel;
|
||||
const onSave = async (val: IPageWidget) => {
|
||||
const {id} = await gristDoc.saveViewSection(activeSection.peek(), val);
|
||||
if (val.type === 'custom') {
|
||||
showCustomWidgetGallery(gristDoc, {sectionRef: id()});
|
||||
}
|
||||
};
|
||||
return (elem) => {
|
||||
attachPageWidgetPicker(elem, gristDoc, onSave, {
|
||||
buttonLabel: t("Save"),
|
||||
value: () => toPageWidget(activeSection.peek()),
|
||||
selectBy: (val) => gristDoc.selectBy(val),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Returns dom for a section item.
|
||||
|
||||
@@ -38,7 +38,8 @@ function isAtScrollTop(elem: Element): boolean {
|
||||
// Indicates that an element is currently scrolled such that the bottom of the element is visible.
|
||||
// It is expected that the elem arg has the offsetHeight property set.
|
||||
function isAtScrollBtm(elem: HTMLElement): boolean {
|
||||
return elem.scrollTop >= (elem.scrollHeight - elem.offsetHeight);
|
||||
// Check we're within a threshold of 1 pixel, to account for possible rounding.
|
||||
return (elem.scrollHeight - elem.offsetHeight - elem.scrollTop) < 1;
|
||||
}
|
||||
|
||||
const cssScrollMenu = styled('div', `
|
||||
|
||||
@@ -119,6 +119,7 @@ export type IconName = "ChartArea" |
|
||||
"Public" |
|
||||
"PublicColor" |
|
||||
"PublicFilled" |
|
||||
"Question" |
|
||||
"Redo" |
|
||||
"Remove" |
|
||||
"RemoveBig" |
|
||||
@@ -280,6 +281,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Public",
|
||||
"PublicColor",
|
||||
"PublicFilled",
|
||||
"Question",
|
||||
"Redo",
|
||||
"Remove",
|
||||
"RemoveBig",
|
||||
|
||||
@@ -471,6 +471,10 @@ export const theme = {
|
||||
undefined, colors.mediumGreyOpaque),
|
||||
rightPanelFieldSettingsButtonBg: new CustomProp('theme-right-panel-field-settings-button-bg',
|
||||
undefined, 'lightgrey'),
|
||||
rightPanelCustomWidgetButtonFg: new CustomProp('theme-right-panel-custom-widget-button-fg',
|
||||
undefined, colors.dark),
|
||||
rightPanelCustomWidgetButtonBg: new CustomProp('theme-right-panel-custom-widget-button-bg',
|
||||
undefined, colors.darkGrey),
|
||||
|
||||
/* Document History */
|
||||
documentHistorySnapshotFg: new CustomProp('theme-document-history-snapshot-fg', undefined,
|
||||
@@ -877,6 +881,20 @@ export const theme = {
|
||||
|
||||
/* Numeric Spinners */
|
||||
numericSpinnerFg: new CustomProp('theme-numeric-spinner-fg', undefined, '#606060'),
|
||||
|
||||
/* Custom Widget Gallery */
|
||||
widgetGalleryBorder: new CustomProp('theme-widget-gallery-border', undefined, colors.darkGrey),
|
||||
widgetGalleryBorderSelected: new CustomProp('theme-widget-gallery-border-selected', undefined,
|
||||
colors.lightGreen),
|
||||
widgetGalleryShadow: new CustomProp('theme-widget-gallery-shadow', undefined, '#0000001A'),
|
||||
widgetGalleryBgHover: new CustomProp('theme-widget-gallery-bg-hover', undefined,
|
||||
colors.lightGrey),
|
||||
widgetGallerySecondaryHeaderFg: new CustomProp('theme-widget-gallery-secondary-header-fg',
|
||||
undefined, colors.light),
|
||||
widgetGallerySecondaryHeaderBg: new CustomProp('theme-widget-gallery-secondary-header-bg',
|
||||
undefined, colors.slate),
|
||||
widgetGallerySecondaryHeaderBgHover: new CustomProp(
|
||||
'theme-widget-gallery-secondary-header-bg-hover', undefined, '#7E7E85'),
|
||||
};
|
||||
|
||||
const cssColors = values(colors).map(v => v.decl()).join('\n');
|
||||
|
||||
@@ -30,12 +30,10 @@ export interface ICustomWidget {
|
||||
* applying the Grist theme.
|
||||
*/
|
||||
renderAfterReady?: boolean;
|
||||
|
||||
/**
|
||||
* If set to false, do not offer to user in UI.
|
||||
*/
|
||||
published?: boolean;
|
||||
|
||||
/**
|
||||
* If the widget came from a plugin, we track that here.
|
||||
*/
|
||||
@@ -43,6 +41,29 @@ export interface ICustomWidget {
|
||||
pluginId: string;
|
||||
name: string;
|
||||
};
|
||||
/**
|
||||
* Widget description.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Widget authors.
|
||||
*
|
||||
* The first author is the one shown in the UI.
|
||||
*/
|
||||
authors?: WidgetAuthor[];
|
||||
/**
|
||||
* Date the widget was last updated.
|
||||
*/
|
||||
lastUpdatedAt?: string;
|
||||
/**
|
||||
* If the widget is maintained by Grist Labs.
|
||||
*/
|
||||
isGristLabsMaintained?: boolean;
|
||||
}
|
||||
|
||||
export interface WidgetAuthor {
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,10 +86,10 @@ export const BehavioralPrompt = StringUnion(
|
||||
'editCardLayout',
|
||||
'addNew',
|
||||
'rickRow',
|
||||
'customURL',
|
||||
'calendarConfig',
|
||||
|
||||
// The following were used in the past and should not be re-used.
|
||||
// 'customURL',
|
||||
// 'formsAreHere',
|
||||
);
|
||||
export type BehavioralPrompt = typeof BehavioralPrompt.type;
|
||||
|
||||
@@ -211,6 +211,8 @@ export const ThemeColors = t.iface([], {
|
||||
"right-panel-toggle-button-disabled-bg": "string",
|
||||
"right-panel-field-settings-bg": "string",
|
||||
"right-panel-field-settings-button-bg": "string",
|
||||
"right-panel-custom-widget-button-fg": "string",
|
||||
"right-panel-custom-widget-button-bg": "string",
|
||||
"document-history-snapshot-fg": "string",
|
||||
"document-history-snapshot-selected-fg": "string",
|
||||
"document-history-snapshot-bg": "string",
|
||||
@@ -438,6 +440,13 @@ export const ThemeColors = t.iface([], {
|
||||
"scroll-shadow": "string",
|
||||
"toggle-checkbox-fg": "string",
|
||||
"numeric-spinner-fg": "string",
|
||||
"widget-gallery-border": "string",
|
||||
"widget-gallery-border-selected": "string",
|
||||
"widget-gallery-shadow": "string",
|
||||
"widget-gallery-bg-hover": "string",
|
||||
"widget-gallery-secondary-header-fg": "string",
|
||||
"widget-gallery-secondary-header-bg": "string",
|
||||
"widget-gallery-secondary-header-bg-hover": "string",
|
||||
});
|
||||
|
||||
const exportedTypeSuite: t.ITypeSuite = {
|
||||
|
||||
@@ -269,6 +269,8 @@ export interface ThemeColors {
|
||||
'right-panel-toggle-button-disabled-bg': string;
|
||||
'right-panel-field-settings-bg': string;
|
||||
'right-panel-field-settings-button-bg': string;
|
||||
'right-panel-custom-widget-button-fg': string;
|
||||
'right-panel-custom-widget-button-bg': string;
|
||||
|
||||
/* Document History */
|
||||
'document-history-snapshot-fg': string;
|
||||
@@ -572,6 +574,15 @@ export interface ThemeColors {
|
||||
|
||||
/* Numeric Spinners */
|
||||
'numeric-spinner-fg': string;
|
||||
|
||||
/* Custom Widget Gallery */
|
||||
'widget-gallery-border': string;
|
||||
'widget-gallery-border-selected': string;
|
||||
'widget-gallery-shadow': string;
|
||||
'widget-gallery-bg-hover': string;
|
||||
'widget-gallery-secondary-header-fg': string;
|
||||
'widget-gallery-secondary-header-bg': string;
|
||||
'widget-gallery-secondary-header-bg-hover': string;
|
||||
}
|
||||
|
||||
export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>;
|
||||
|
||||
@@ -759,7 +759,8 @@ export interface GristLoadConfig {
|
||||
// List of registered plugins (used by HomePluginManager and DocPluginManager)
|
||||
plugins?: LocalPlugin[];
|
||||
|
||||
// If custom widget list is available.
|
||||
// If additional custom widgets (besides the Custom URL widget) should be shown in
|
||||
// the custom widget gallery.
|
||||
enableWidgetRepository?: boolean;
|
||||
|
||||
// Whether there is somewhere for survey data to go.
|
||||
|
||||
@@ -248,6 +248,8 @@ export const GristDark: ThemeColors = {
|
||||
'right-panel-toggle-button-disabled-bg': '#32323F',
|
||||
'right-panel-field-settings-bg': '#404150',
|
||||
'right-panel-field-settings-button-bg': '#646473',
|
||||
'right-panel-custom-widget-button-fg': '#EFEFEF',
|
||||
'right-panel-custom-widget-button-bg': '#60606D',
|
||||
|
||||
/* Document History */
|
||||
'document-history-snapshot-fg': '#EFEFEF',
|
||||
@@ -551,4 +553,13 @@ export const GristDark: ThemeColors = {
|
||||
|
||||
/* Numeric Spinners */
|
||||
'numeric-spinner-fg': '#A4A4B1',
|
||||
|
||||
/* Custom Widget Gallery */
|
||||
'widget-gallery-border': '#555563',
|
||||
'widget-gallery-border-selected': '#17B378',
|
||||
'widget-gallery-shadow': '#00000080',
|
||||
'widget-gallery-bg-hover': '#262633',
|
||||
'widget-gallery-secondary-header-fg': '#FFFFFF',
|
||||
'widget-gallery-secondary-header-bg': '#70707D',
|
||||
'widget-gallery-secondary-header-bg-hover': '#60606D',
|
||||
};
|
||||
|
||||
@@ -248,6 +248,8 @@ export const GristLight: ThemeColors = {
|
||||
'right-panel-toggle-button-disabled-bg': '#E8E8E8',
|
||||
'right-panel-field-settings-bg': '#E8E8E8',
|
||||
'right-panel-field-settings-button-bg': 'lightgrey',
|
||||
'right-panel-custom-widget-button-fg': '#262633',
|
||||
'right-panel-custom-widget-button-bg': '#D9D9D9',
|
||||
|
||||
/* Document History */
|
||||
'document-history-snapshot-fg': '#262633',
|
||||
@@ -551,4 +553,13 @@ export const GristLight: ThemeColors = {
|
||||
|
||||
/* Numeric Spinners */
|
||||
'numeric-spinner-fg': '#606060',
|
||||
|
||||
/* Custom Widget Gallery */
|
||||
'widget-gallery-border': '#D9D9D9',
|
||||
'widget-gallery-border-selected': '#16B378',
|
||||
'widget-gallery-shadow': '#0000001A',
|
||||
'widget-gallery-bg-hover': '#F7F7F7',
|
||||
'widget-gallery-secondary-header-fg': '#FFFFFF',
|
||||
'widget-gallery-secondary-header-bg': '#929299',
|
||||
'widget-gallery-secondary-header-bg-hover': '#7E7E85',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user