var _ = require('underscore'); var dispose = require('app/client/lib/dispose'); var TextEditor = require('app/client/widgets/TextEditor'); const {Autocomplete} = require('app/client/lib/autocomplete'); const {ACIndexImpl, buildHighlightedDom} = require('app/client/lib/ACIndex'); const {makeT} = require('app/client/lib/localization'); const { buildDropdownConditionFilter, ChoiceItem, cssChoiceList, cssMatchText, cssPlusButton, cssPlusIcon, } = require('app/client/widgets/ChoiceListEditor'); const {icon} = require('app/client/ui2018/icons'); const {menuCssClass} = require('app/client/ui2018/menus'); const {testId, theme} = require('app/client/ui2018/cssVars'); const {choiceToken, cssChoiceACItem} = require('app/client/widgets/ChoiceToken'); const {dom, styled} = require('grainjs'); const t = makeT('ChoiceEditor'); /** * ChoiceEditor - TextEditor with a dropdown for possible choices. */ function ChoiceEditor(options) { TextEditor.call(this, options); this.widgetOptionsJson = options.field.widgetOptionsJson; this.choices = this.widgetOptionsJson.peek().choices || []; this.choicesSet = new Set(this.choices); this.choiceOptions = this.widgetOptionsJson.peek().choiceOptions || {}; this.hasDropdownCondition = Boolean(options.field.dropdownCondition.peek()?.text); this.dropdownConditionError; let acItems = this.choices.map(c => new ChoiceItem(c, false, false)); if (this.hasDropdownCondition) { try { const dropdownConditionFilter = this.buildDropdownConditionFilter(); acItems = acItems.filter((item) => dropdownConditionFilter(item)); } catch (e) { acItems = []; this.dropdownConditionError = e.message; } } const acIndex = new ACIndexImpl(acItems); this._acOptions = { popperOptions: { placement: 'bottom' }, menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`, buildNoItemsMessage: this.buildNoItemsMessage.bind(this), search: (term) => this.maybeShowAddNew(acIndex.search(term), term), renderItem: (item, highlightFunc) => this.renderACItem(item, highlightFunc), getItemText: (item) => item.label, onClick: () => this.options.commands.fieldEditSave(), }; if (!options.readonly && options.field.viewSection().parentKey() === "single") { this.cellEditorDiv.classList.add(cssChoiceEditor.className); this.cellEditorDiv.appendChild(cssChoiceEditIcon('Dropdown')); } // Whether to include a button to show a new choice. // TODO: Disable when the user cannot change column configuration. this.enableAddNew = !this.hasDropdownCondition; } dispose.makeDisposable(ChoiceEditor); _.extend(ChoiceEditor.prototype, TextEditor.prototype); ChoiceEditor.prototype.getCellValue = function() { const selectedItem = this.autocomplete && this.autocomplete.getSelectedItem(); if (selectedItem) { return selectedItem.label; } else if (this.textInput.value.trim() === '') { return null; } else { return TextEditor.prototype.getCellValue.call(this); } } ChoiceEditor.prototype.renderACItem = function(item, highlightFunc) { const options = this.choiceOptions[item.label]; return cssChoiceACItem( (item.isNew ? [cssChoiceACItem.cls('-new'), cssPlusButton(cssPlusIcon('Plus')), testId('choice-editor-new-item')] : [cssChoiceACItem.cls('-with-new', this.showAddNew)] ), choiceToken( buildHighlightedDom(item.label, highlightFunc, cssMatchText), options || {}, dom.style('max-width', '100%'), testId('choice-editor-item-label') ), testId('choice-editor-item'), ); } ChoiceEditor.prototype.attach = function(cellElem) { TextEditor.prototype.attach.call(this, cellElem); // Don't create autocomplete if readonly. if (this.options.readonly) { return; } this.autocomplete = Autocomplete.create(this, this.textInput, this._acOptions); } /** * Updates list of valid choices with any new ones that may have been * added from directly inside the editor (via the "add new" item in autocomplete). */ ChoiceEditor.prototype.prepForSave = async function() { const selectedItem = this.autocomplete && this.autocomplete.getSelectedItem(); if (selectedItem && selectedItem.isNew) { const choices = this.widgetOptionsJson.prop('choices'); await choices.saveOnly([...(choices.peek() || []), selectedItem.label]); } } ChoiceEditor.prototype.buildDropdownConditionFilter = function() { const dropdownConditionCompiled = this.options.field.dropdownConditionCompiled.get(); if (dropdownConditionCompiled?.kind !== 'success') { throw new Error('Dropdown condition is not compiled'); } return buildDropdownConditionFilter({ dropdownConditionCompiled: dropdownConditionCompiled.result, docData: this.options.gristDoc.docData, tableId: this.options.field.tableId(), rowId: this.options.rowId, }); } ChoiceEditor.prototype.buildNoItemsMessage = function() { if (this.dropdownConditionError) { return t('Error in dropdown condition'); } else if (this.hasDropdownCondition) { return t('No choices matching condition'); } else { return t('No choices to select'); } } /** * If the search text does not match anything exactly, adds 'new' item to it. * * Also see: prepForSave. */ ChoiceEditor.prototype.maybeShowAddNew = function(result, text) { // TODO: This logic is also mostly duplicated in ChoiceListEditor and ReferenceEditor. // See if there's anything common we can factor out and re-use. this.showAddNew = false; if (!this.enableAddNew) { return result; } const trimmedText = text.trim(); if (!trimmedText || this.choicesSet.has(trimmedText)) { return result; } const addNewItem = new ChoiceItem(trimmedText, false, false, true); if (result.items.find((item) => item.cleanText === addNewItem.cleanText)) { return result; } result.extraItems.push(addNewItem); this.showAddNew = true; return result; } const cssChoiceEditIcon = styled(icon, ` background-color: ${theme.lightText}; position: absolute; top: 0; left: 0; margin: 3px 3px 0 3px; `); const cssChoiceEditor = styled('div', ` & > .celleditor_text_editor, & > .celleditor_content_measure { padding-left: 18px; } `); module.exports = ChoiceEditor;