(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:
George Gevoian
2024-08-13 19:21:48 -04:00
parent a16d76d25d
commit e70c294e3d
32 changed files with 1672 additions and 785 deletions

View File

@@ -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);

View File

@@ -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.

View File

@@ -134,6 +134,10 @@ div:hover > .kf_tooltip {
z-index: 11;
}
.kf_prompt_content:focus {
outline: none;
}
.kf_draggable {
display: inline-block;
}

View File

@@ -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});
}

View File

@@ -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;
`);

View 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};
`);

View File

@@ -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'),

View File

@@ -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 [];
}
}

View File

@@ -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.

View File

@@ -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', `

View File

@@ -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",

View File

@@ -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');

View File

@@ -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;
}
/**

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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>;

View File

@@ -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.

View File

@@ -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',
};

View File

@@ -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',
};