(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

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