You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/client/ui/CustomWidgetGallery.ts

662 lines
19 KiB

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