import {allCommands} from 'app/client/components/commands'; import {GristDoc} from 'app/client/components/GristDoc'; import {makeTestId} from 'app/client/lib/domUtils'; import * as kf from 'app/client/lib/koForm'; import {makeT} from 'app/client/lib/localization'; import {ColumnToMapImpl} from 'app/client/models/ColumnToMap'; import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; import {reportError} from 'app/client/models/errors'; 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 {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, fromKo, MultiHolder, Observable, styled, UseCBOwner } from 'grainjs'; 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, 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{ // Does widget has custom configuration. private readonly _hasConfiguration: Computed; 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, () => 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.attachTip('calendarConfig', { popupOptions: {placement: 'left-start'}, }); } default: { return null; } } } } export class CustomSectionConfig extends Disposable { protected _customSectionConfigurationConfig: CustomSectionConfigurationConfig; // Holds all available widget definitions. private _widgets: Observable; // Holds selected option (either custom string or a widgetId). private readonly _selectedId: Computed; // Holds custom widget URL. private readonly _url: Computed; // 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; // Current access level (stored inside a section). private readonly _currentAccess: Computed; 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; // Array of available widgets - will be updated asynchronously. this._widgets = _gristDoc.app.topAppModel.customWidgets; this._getWidgets().catch(reportError); // Request for rest of the widgets. // 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); // Clear intermediate state when section changes. this.autoDispose(_section.id.subscribe(() => this._reject())); } 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[] = [ {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.attachTip('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(), this._customSectionConfigurationConfig.buildDom(), ); } protected shouldRenderWidgetSelector(): boolean { return true; } protected async _getWidgets() { await this._gristDoc.app.topAppModel.getWidgets(); } private _accept() { if (this._desiredAccess.get()) { this._currentAccess.set(this._desiredAccess.get()!); } this._reject(); } private _reject() { this._desiredAccess.set(null); } } 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 cssMenu = styled('div', ` & > li:first-child { border-bottom: 1px solid ${theme.menuBorder}; } `); 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, ` 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}; } `); const cssDisabledSelect = styled(select, ` opacity: unset !important; `); const cssBlank = styled(cssOptionLabel, ` --grist-option-label-color: ${theme.lightText}; `);