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 { 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'; import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons'; import {theme, vars} from 'app/client/ui2018/cssVars'; import {cssDragger} from 'app/client/ui2018/draggableList'; import {textInput} from 'app/client/ui2018/editableLabel'; import {icon} from 'app/client/ui2018/icons'; import {cssOptionLabel, IOption, IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus'; import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget'; import {not, unwrap} from 'app/common/gutil'; import { bundleChanges, Computed, Disposable, dom, DomContents, fromKo, MultiHolder, Observable, styled, UseCBOwner } from 'grainjs'; const t = makeT('CustomSectionConfig'); const testId = makeTestId('test-config-widget-'); class ColumnPicker extends Disposable { constructor( private _value: Observable, private _column: ColumnToMapImpl, private _section: ViewSectionRec){ super(); } public buildDom() { // Rewrite value to ignore old configuration when allowMultiple is switched. const properValue = Computed.create(this, use => { const value = use(this._value); return Array.isArray(value) ? null : value; }); properValue.onWrite(value => this._value.set(value || null)); const canBeMapped = Computed.create(this, use => { return use(this._section.columns) .filter(col => this._column.canByMapped(use(col.pureType))); }); // This is a HACK, to refresh options only when the menu is opened (or closed) // and not to track down all the dependencies. Otherwise the select menu won't // be hidden when option is selected - there is a bug that prevents it from closing // when list of options is changed. const refreshTrigger = Observable.create(this, false); const options = Computed.create(this, use => { void use(refreshTrigger); const columnsAsOptions: IOption[] = use(canBeMapped) .map((col) => ({ value: col.getRowId(), label: col.label.peek(), icon: 'FieldColumn', })); // For optional mappings, add 'Blank' option but only if the value is set. // This option will allow to clear the selection. if (this._column.optional && properValue.get()) { columnsAsOptions.push({ value: 0, // Another hack. Select doesn't allow to have different label for blank option and the default text. // So we will render this label ourselves later using `renderOptionArgs`. label: '', }); } return columnsAsOptions; }); const isDisabled = Computed.create(this, use => { return use(canBeMapped).length === 0; }); const defaultLabel = this._column.typeDesc != "any" ? t("Pick a {{columnType}} column", {"columnType": this._column.typeDesc}) : t("Pick a column"); return [ cssLabel( this._column.title, this._column.optional ? cssSubLabel(t(" (optional)")) : null, testId('label-for-' + this._column.name), ), this._column.description ? cssHelp( this._column.description, testId('help-for-' + this._column.name), ) : null, dom.maybe(not(isDisabled), () => [ cssRow( dom.update( select( properValue, options, { defaultLabel, renderOptionArgs : (opt) => { // If there is a label, render it. // Otherwise show the 'Clear selection' label as a greyed out text. // This is the continuation of the hack from above - were we added an option // without a label. return (opt.label) ? null : [ cssBlank(t("Clear selection")), testId('clear-selection'), ]; } } ), dom.on('click', () => { // When the menu is opened or closed, refresh the options. refreshTrigger.set(!refreshTrigger.get()); }) ), testId('mapping-for-' + this._column.name), testId('enabled'), ), ]), dom.maybe(isDisabled, () => [ cssRow( cssDisabledSelect( Observable.create(this, null), [], { disabled: true, defaultLabel: t("No {{columnType}} columns in table.", {"columnType": this._column.typeDesc}) } ), hoverTooltip(t("No {{columnType}} columns in table.", {"columnType": this._column.typeDesc})), testId('mapping-for-' + this._column.name), testId('disabled'), ), ]), ]; } } class ColumnListPicker extends Disposable { constructor( private _value: Observable, private _column: ColumnToMapImpl, private _section: ViewSectionRec) { super(); } public buildDom() { return dom.domComputed((use) => { return [ cssLabel(this._column.title, cssLabel.cls("-required", !this._column.optional), testId('label-for-' + this._column.name), ), this._buildDraggableList(use), this._buildAddColumn() ]; }); } private _buildAddColumn() { const owner = MultiHolder.create(null); const notMapped = Computed.create(owner, use => { const value = use(this._value) || []; const mapped = !Array.isArray(value) ? [] : value; return this._section.columns().filter(col => !mapped.includes(use(col.id))); }); const typedColumns = Computed.create(owner, use => { return use(notMapped).filter(this._typeFilter(use)); }); return [ cssRow( dom.autoDispose(owner), cssAddMapping( cssAddIcon('Plus'), t("Add") + ' ' + this._column.title, dom.cls('disabled', use => use(notMapped).length === 0), testId('disabled', use => use(notMapped).length === 0), menu(() => { const wrongTypeCount = notMapped.get().length - typedColumns.get().length; return [ ...typedColumns.get() .map((col) => menuItem( () => this._addColumn(col), col.label.peek(), )), wrongTypeCount > 0 ? menuText( t("{{wrongTypeCount}} non-{{columnType}} columns are not shown", { wrongTypeCount, columnType: this._column.type.toLowerCase(), count: wrongTypeCount }), testId('map-message-' + this._column.name) ) : null ]; }), testId('add-column-for-' + this._column.name), ) ), ]; } // Helper method for filtering columns that can be picked by the widget. private _typeFilter = (use = unwrap) => (col: ColumnRec|null) => !col ? false : this._column.canByMapped(use(col.pureType)); private _buildDraggableList(use: UseCBOwner) { return dom.update(kf.draggableList( this._readItems(use), this._renderItem.bind(this, use), { itemClass: cssDragRow.className, reorder: this._reorder.bind(this), receive: this._addColumn.bind(this), drag_indicator: cssDragger, } ), testId('map-list-for-' + this._column.name)); } private _readItems(use: UseCBOwner): ColumnRec[] { let selectedRefs = (use(this._value) || []) as number[]; // Ignore if configuration was changed from what it was saved. if (!Array.isArray(selectedRefs)) { selectedRefs = []; } // Filter columns by type - when column type has changed since mapping. const columns = use(this._section.columns).filter(this._typeFilter(use)); const columnMap = new Map(columns.map(c => [c.id.peek(), c])); // Remove any columns that are no longer there. return selectedRefs.map(s => columnMap.get(s)!).filter(c => Boolean(c)); } private _renderItem(use: UseCBOwner, field: ColumnRec): any { return cssFieldEntry( cssFieldLabel( dom.text(field.label), testId('ref-select-label'), ), cssRemoveIcon( 'Remove', dom.on('click', () => this._remove(field)), testId('ref-select-remove'), ), ); } // Helper method that for accessing mapped columns. Can be used to set and retrieve the value. private _list(value: number[]): void private _list(): number[] private _list(value?: number[]) { if (value) { this._value.set(value); } else { let current = (this._value.get() || []) as number[]; // Ignore if the saved value is not a number list. if (!Array.isArray(current)) { current = []; } return current; } } private _reorder(column: ColumnRec, nextColumn: ColumnRec|null): any { const id = column.id.peek(); const nextId = nextColumn?.id.peek(); const currentList = this._list(); const indexOfId = currentList.indexOf(id); // Remove element from the list. currentList.splice(indexOfId, 1); const indexOfNext = nextId ? currentList.indexOf(nextId) : currentList.length; // Insert before next element or at the end. currentList.splice(indexOfNext, 0, id); this._list(currentList); } private _remove(column: ColumnRec): any { const current = this._list(); this._value.set(current.filter(c => c != column.id.peek())); } private _addColumn(col: ColumnRec): any { // Helper to find column model. const model = (id: number) => this._section.columns().find(c => c.id.peek() === id) || null; // Get the list of currently mapped columns. let current = this._list(); // Add new column. current.push(col.id.peek()); // Remove those that don't exists anymore. current = current.filter(c => model(c)); // And those with wrong type. current = current.filter(c => this._typeFilter()(model(c))); this._value.set(current); } } class CustomSectionConfigurationConfig extends Disposable{ private readonly _hasConfiguration = Computed.create(this, use => Boolean(use(this._section.hasCustomOptions) || use(this._section.columnsToMap))); constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) { super(); } public buildDom() { return dom.maybe(this._hasConfiguration, () => [ cssSeparator(), dom.maybe(this._section.hasCustomOptions, () => cssSection( textButton( t("Open configuration"), dom.on('click', () => this._openConfiguration()), testId('open-configuration') ) ) ), dom.maybeOwned(use => use(this._section.columnsToMap), (owner, columns) => { const createObs = (column: ColumnToMapImpl) => { const obs = Computed.create(owner, use => { const savedDefinition = use(this._section.customDef.columnsMapping) || {}; return savedDefinition[column.name]; }); obs.onWrite(async (value) => { const savedDefinition = this._section.customDef.columnsMapping.peek() || {}; savedDefinition[column.name] = value; await this._section.customDef.columnsMapping.setAndSave(savedDefinition); }); return obs; }; // Create observables for all columns to pick. const mappings = columns.map(c => new ColumnToMapImpl(c)).map((column) => ({ value: createObs(column), column })); return dom('div', this._attachColumnMappingTip(this._section.customDef.url()), ...mappings.map(m => m.column.allowMultiple ? dom.create(ColumnListPicker, m.value, m.column, this._section) : dom.create(ColumnPicker, m.value, m.column, this._section)), ); }) ]); } private _openConfiguration(): void { allCommands.openWidgetConfiguration.run(); } private _attachColumnMappingTip(widgetUrl: string | null) { switch (widgetUrl) { // TODO: come up with a way to attach tips without hardcoding widget URLs. case 'https://gristlabs.github.io/grist-widget/calendar/index.html': { return this._gristDoc.behavioralPromptsManager.attachPopup('calendarConfig', { popupOptions: {placement: 'left-start'}, }); } default: { return null; } } } } /** * 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); 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; private readonly _widgets: Observable = 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(); const userId = this._gristDoc.appModel.currentUser?.id ?? 0; this._widgetDetailsExpanded = this.autoDispose(localStorageBoolObs( `u:${userId};customWidgetDetailsExpanded`, true )); this._getWidgets() .then(widgets => { if (this.isDisposed()) { return; } this._widgets.set(widgets); }) .catch(reportError); // Clear intermediate state when section changes. this.autoDispose(_section.id.subscribe(() => this._dismissAccessPrompt())); } public buildDom(): DomContents { return dom('div', this._buildWidgetSelector(), this._buildAccessLevelConfig(), this._customSectionConfigurationConfig.buildDom(), ); } protected shouldRenderWidgetSelector(): boolean { return true; } protected async _getWidgets() { return await this._gristDoc.app.topAppModel.getWidgets(); } 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._dismissAccessPrompt(); } private _dismissAccessPrompt() { this._desiredAccess.set(null); } } function getAccessLevels(): IOptionFull[] { 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; --icon-color: ${theme.iconError} `); const cssColumns = styled('div', ` display: flex; `); const cssConfirmRow = styled('div', ` display: flex; padding: 8px; gap: 8px; `); const cssConfirmLine = styled('span', ` white-space: pre-wrap; `); const cssSection = styled('div', ` margin: 16px 16px 12px 16px; `); const cssAddIcon = styled(icon, ` margin-right: 4px; `); const cssRemoveIcon = styled(icon, ` display: none; cursor: pointer; flex: none; margin-left: 8px; .${cssFieldEntry.className}:hover & { display: block; } `); // Additional text in label (greyed out) const cssSubLabel = styled('span', ` text-transform: none; font-size: ${vars.xsmallFontSize}; color: ${theme.lightText}; `); const cssAddMapping = styled('div', ` display: flex; cursor: pointer; color: ${theme.controlFg}; --icon-color: ${theme.controlFg}; &:not(:first-child) { margin-top: 8px; } &:hover, &:focus, &:active { color: ${theme.controlHoverFg}; --icon-color: ${theme.controlHoverFg}; } &.disabled { color: ${theme.lightText}; --icon-color: ${theme.lightText}; pointer-events: none; } `); const cssTextInput = styled(textInput, ` color: ${theme.inputFg}; background-color: ${theme.inputBg}; &::placeholder { color: ${theme.inputPlaceholderFg}; } `); const cssDisabledSelect = styled(select, ` opacity: unset !important; `); 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; `);