From 1477f3f00b3b1fce8ab275f872b378e7cc3a2f60 Mon Sep 17 00:00:00 2001 From: Viktor <38672169+vdedek@users.noreply.github.com> Date: Fri, 13 Mar 2026 03:37:48 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20Stimulus=20OrderedMultiselect=20?= =?UTF-8?q?=E2=80=94=20createable=20mode=20with=20inline=20item=20manageme?= =?UTF-8?q?nt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new Stimulus controller f-input-ordered-multiselect with stimulus_ordered_multiselect helper for managing has_many :through relations with inline CRUD (create, rename, delete) directly in the select dropdown. Uses Tom Select (single mode) for async search + html5sortable for drag-and-drop reordering. Reuses existing react_select autocomplete endpoints and adds three new API actions (create/update/destroy) with CanCan authorization and class validation. --- app/assets/javascripts/folio/input.js | 1 + .../folio/input/ordered_multiselect.js | 1133 +++++++++++++++++ app/assets/stylesheets/folio/_input.sass | 1 + .../folio/input/_ordered_multiselect.sass | 152 +++ .../console/api/autocompletes_controller.rb | 87 +- app/helpers/folio/console/react_helper.rb | 126 ++ config/routes.rb | 3 + 7 files changed, 1502 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/folio/input/ordered_multiselect.js create mode 100644 app/assets/stylesheets/folio/input/_ordered_multiselect.sass diff --git a/app/assets/javascripts/folio/input.js b/app/assets/javascripts/folio/input.js index 303c750e65..fbd60fe18d 100644 --- a/app/assets/javascripts/folio/input.js +++ b/app/assets/javascripts/folio/input.js @@ -7,6 +7,7 @@ //= require folio/input/date_time //= require folio/input/embed //= require folio/input/multiselect +//= require folio/input/ordered_multiselect //= require folio/input/numeral //= require folio/input/phone //= require folio/input/redactor diff --git a/app/assets/javascripts/folio/input/ordered_multiselect.js b/app/assets/javascripts/folio/input/ordered_multiselect.js new file mode 100644 index 0000000000..aaed42639c --- /dev/null +++ b/app/assets/javascripts/folio/input/ordered_multiselect.js @@ -0,0 +1,1133 @@ +//= require tom-select.complete +//= require folio/i18n +//= require folio/api +//= require folio/remote_scripts +//= require folio/debounce + +window.Folio = window.Folio || {} +window.Folio.Input = window.Folio.Input || {} +window.Folio.Input.OrderedMultiselect = {} + +window.Folio.Input.OrderedMultiselect.I18n = { + cs: { + add: 'Přidat...', + remove: 'Odebrat', + create: 'Vytvořit', + rename: 'Přejmenovat', + cancel: 'Zrušit', + deleteFromDb: 'Smazat', + deleteFromDbConfirm: 'Smazat tento záznam z databáze?', + deleteWarning: 'Tato položka je přiřazena k %{count} dalším záznamům. Smazání ji odebere ze všech. Pokračovat?', + deleteWarningWithLabels: 'Tato položka je přiřazena k %{count} záznamům:\n%{list}\n\nSmazáním z databáze ji odeberete ze všech.', + alreadyExists: 'Položka s tímto názvem již existuje.', + alreadyOnList: 'Tato položka je již na seznamu.', + deleted: 'Smazáno', + usedIn: 'Použito v:', + notUsed: 'Nikde nepoužito', + noResults: 'Žádné výsledky' + }, + en: { + add: 'Add...', + remove: 'Remove', + create: 'Create', + rename: 'Rename', + cancel: 'Cancel', + deleteFromDb: 'Delete', + deleteFromDbConfirm: 'Delete this record from database?', + deleteWarning: 'This item is assigned to %{count} other records. Deleting it will remove it from all of them. Continue?', + deleteWarningWithLabels: 'This item is assigned to %{count} records:\n%{list}\n\nDeleting it from the database will remove it from all of them.', + alreadyExists: 'An item with this name already exists.', + alreadyOnList: 'This item is already on the list.', + deleted: 'Deleted', + usedIn: 'Used in:', + notUsed: 'Not used anywhere', + noResults: 'No results' + } +} + +window.Folio.Input.OrderedMultiselect.t = (key) => { + return window.Folio.i18n(window.Folio.Input.OrderedMultiselect.I18n, key) +} + +window.Folio.Input.OrderedMultiselect.escapeHtml = (str) => { + const div = document.createElement('div') + div.textContent = str + return div.innerHTML +} + +window.Folio.Input.OrderedMultiselect.isDuplicateLabel = (value, currentLabel, existingLabels, loadedOptions) => { + if (!value || !value.trim()) return false + const normalized = value.trim().toLowerCase() + if (currentLabel && normalized === currentLabel.toLowerCase()) return false + if (existingLabels && existingLabels.some((l) => l.toLowerCase().trim() === normalized)) return true + if (loadedOptions) { + const opts = Array.isArray(loadedOptions) ? loadedOptions : Object.values(loadedOptions) + if (opts.some((o) => o.text && o.text.toLowerCase().trim() === normalized)) return true + } + return false +} + +window.Folio.Input.OrderedMultiselect.iconHtml = (name, opts) => { + const cacheKey = `_icon_${name}` + if (!window.Folio.Input.OrderedMultiselect[cacheKey]) { + if (window.Folio.Ui && window.Folio.Ui.Icon) { + window.Folio.Input.OrderedMultiselect[cacheKey] = window.Folio.Ui.Icon.create(name, opts || { height: 16 }).outerHTML + } else { + window.Folio.Input.OrderedMultiselect[cacheKey] = name + } + } + return window.Folio.Input.OrderedMultiselect[cacheKey] +} + +window.Folio.Input.OrderedMultiselect.usageHintHtml = (usageLabels, cssClass, showEmpty) => { + const t = window.Folio.Input.OrderedMultiselect.t + if (!usageLabels || usageLabels.length === 0) { + if (!showEmpty) return '' + return `${t('notUsed')}` + } + const escape = window.Folio.Input.OrderedMultiselect.escapeHtml + return `${t('usedIn')} ${escape(usageLabels.join(', '))}` +} + +window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends window.Stimulus.Controller { + static values = { + items: { type: Array, default: [] }, + removedItems: { type: Array, default: [] }, + url: String, + paramBase: String, + foreignKey: String, + sortable: { type: Boolean, default: true }, + currentRecordLabel: { type: String, default: '' }, + createable: { type: Boolean, default: false }, + createUrl: String, + updateUrl: String, + deleteUrl: String + } + + static targets = ['list', 'select', 'hiddenContainer'] + + connect () { + this.loadedOptions = {} + this.initTomSelect() + + if (this.sortableValue) { + this.initSortable() + } + + this.renderList() + this.syncHiddenInputs() + } + + disconnect () { + if (this._onDocumentMousedown) { + document.removeEventListener('mousedown', this._onDocumentMousedown, true) + this._onDocumentMousedown = null + } + this.destroyTomSelect() + this.destroySortable() + } + + // --- Tom Select --- + + initTomSelect () { + const self = this + const t = window.Folio.Input.OrderedMultiselect.t + + const config = { + placeholder: t('add'), + plugins: {}, + valueField: 'value', + labelField: 'text', + searchField: ['text'], + sortField: [{ field: 'text', direction: 'asc' }], + score (search) { + const lc = search.toLowerCase() + return function (item) { + return (item.text || '').toLowerCase().indexOf(lc) !== -1 ? 1 : 0 + } + }, + loadThrottle: 300, + openOnFocus: true, + maxItems: 1, + preload: 'focus', + closeAfterSelect: true, + + load (query, callback) { + const separator = self.urlValue.includes('?') ? '&' : '?' + const url = `${self.urlValue}${separator}q=${encodeURIComponent(query)}` + + fetch(url, { + headers: window.Folio.Api.JSON_HEADERS, + credentials: 'same-origin' + }) + .then((r) => r.json()) + .then((json) => { + const data = (json.data || []).map((item) => ({ + value: String(item.id), + text: item.label || item.text, + usage_labels: item.usage_labels || [] + })) + + // Filter out already selected items + const selectedIds = self.itemsValue.map((i) => String(i.value)) + const filtered = data.filter((d) => !selectedIds.includes(d.value)) + + // Accumulate loaded options for duplicate detection + filtered.forEach((opt) => { self.loadedOptions[opt.value] = opt }) + + callback(filtered) + }) + .catch(() => { + callback() + }) + }, + + render: { + option (data, escape) { + if (self.createableValue) { + return self.renderOptionWithActions(data, escape) + } + return `
${escape(data.text)}
` + }, + + option_create (data, escape) { + return `
+ ${t('create')} ${escape(data.input)}… +
` + }, + + no_results (data) { + if (data.input) { + const existingLabels = self.itemsValue.map((i) => i.label) + if (existingLabels.some((l) => l.toLowerCase().trim() === data.input.toLowerCase().trim())) { + return `
${t('alreadyOnList')}
` + } + } + return `
${t('noResults')}
` + }, + + loading () { + return `
` + } + }, + + onChange (value) { + if (!value) return + self.onItemSelected(value) + // Reset Tom Select — clear value, keep options so dropdown can reopen + window.setTimeout(() => { + if (self.tomSelect) { + self.tomSelect.clear(true) + self._needsReload = true + } + }, 0) + } + } + + if (this.createableValue) { + config.create = (input, callback) => { + // Don't add to Tom Select's internal state — we handle it via API + self.createItem(input) + callback() + } + + config.createFilter = (input) => { + const existingLabels = self.itemsValue.map((i) => i.label) + return !window.Folio.Input.OrderedMultiselect.isDuplicateLabel( + input, null, existingLabels, self.loadedOptions + ) + } + } + + this.tomSelect = new window.TomSelect(this.selectTarget, config) + + this.tomSelect.on('dropdown_open', () => { + if (this._needsReload) { + this._needsReload = false + this.tomSelect.clearOptions() + this.tomSelect.load('') + } + this.adjustDropdownMaxHeight() + }) + + this.bindDropdownEvents() + } + + adjustDropdownMaxHeight () { + if (!this.tomSelect) return + const content = this.tomSelect.dropdown.querySelector('.ts-dropdown-content') + if (!content) return + + const footer = document.querySelector('.f-c-form-footer') + const bottomLimit = footer ? footer.getBoundingClientRect().top : window.innerHeight + const dropdownTop = this.tomSelect.dropdown.getBoundingClientRect().top + const available = bottomLimit - dropdownTop - 10 + + content.style.maxHeight = Math.max(available, 80) + 'px' + } + + destroyTomSelect () { + this.unbindDropdownEvents() + if (this.tomSelect) { + this.tomSelect.destroy() + delete this.tomSelect + } + } + + bindDropdownEvents () { + if (!this.tomSelect) return + const dropdown = this.tomSelect.dropdown + + this._onDropdownMousedown = (e) => { + const submitBtn = e.target.closest('.f-input-ordered-multiselect__option-confirm') + const deleteBtn = e.target.closest('.f-input-ordered-multiselect__option-action--danger') + const renameBtn = e.target.closest('.f-input-ordered-multiselect__option-action:not(.f-input-ordered-multiselect__option-action--danger):not(.f-input-ordered-multiselect__option-confirm)') + + if (submitBtn || deleteBtn || renameBtn) { + e.preventDefault() + e.stopPropagation() + // Tom Select selects options on 'click' (not mousedown). + // Block the subsequent click so it doesn't trigger option selection. + // Store reference so it can be removed if rename is confirmed via Enter (no click follows). + if (this._pendingClickBlocker) { + dropdown.removeEventListener('click', this._pendingClickBlocker, true) + } + this._pendingClickBlocker = (ce) => { ce.preventDefault(); ce.stopPropagation(); this._pendingClickBlocker = null } + dropdown.addEventListener('click', this._pendingClickBlocker, { capture: true, once: true }) + } + + if (submitBtn) { + this.onOptionRenameSubmit({ currentTarget: submitBtn, preventDefault: () => {}, stopPropagation: () => {} }) + } else if (deleteBtn) { + this.onOptionDeleteClick({ currentTarget: deleteBtn, preventDefault: () => {}, stopPropagation: () => {} }) + } else if (renameBtn) { + this.onOptionRenameClick({ currentTarget: renameBtn, preventDefault: () => {}, stopPropagation: () => {} }) + } + } + + this._onDropdownKeydown = (e) => { + const input = e.target.closest('.f-input-ordered-multiselect__option-edit-input') + if (!input) return + + if (e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + this.onOptionRenameSubmit({ currentTarget: input, preventDefault: () => {}, stopPropagation: () => {} }) + } else if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + this.cancelOptionRename(input) + } + } + + this._onDropdownInput = (e) => { + const input = e.target.closest('.f-input-ordered-multiselect__option-edit-input') + if (!input) return + this.onOptionRenameInput({ currentTarget: input }) + } + + dropdown.addEventListener('mousedown', this._onDropdownMousedown, true) + dropdown.addEventListener('keydown', this._onDropdownKeydown, true) + dropdown.addEventListener('input', this._onDropdownInput, true) + } + + unbindDropdownEvents () { + if (!this.tomSelect) return + const dropdown = this.tomSelect.dropdown + if (this._onDropdownMousedown) dropdown.removeEventListener('mousedown', this._onDropdownMousedown, true) + if (this._onDropdownKeydown) dropdown.removeEventListener('keydown', this._onDropdownKeydown, true) + if (this._onDropdownInput) dropdown.removeEventListener('input', this._onDropdownInput, true) + } + + renderOptionWithActions (data, escape) { + // Don't add actions to "create new" options + if (String(data.value).startsWith('__create__')) { + return `
${escape(data.text)}
` + } + + const t = window.Folio.Input.OrderedMultiselect.t + + const editIcon = window.Folio.Input.OrderedMultiselect.iconHtml('edit_box', { height: 16 }) + const deleteIcon = window.Folio.Input.OrderedMultiselect.iconHtml('delete', { height: 16 }) + const usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml(data.usage_labels, 'f-input-ordered-multiselect__option-usage', true) + + return `
+ + ${escape(data.text)} + ${usageHint} + + + + + +
` + } + + // --- Item selection --- + + onItemSelected (value) { + const valueStr = String(value) + + // Skip create values — handled directly in Tom Select create callback + if (valueStr.startsWith('__create__')) return + + // Check if already selected + if (this.itemsValue.find((i) => String(i.value) === valueStr)) return + + // Check if was previously removed — restore it + const removed = this.removedItemsValue.find((i) => String(i.value) === valueStr) + if (removed) { + this.removedItemsValue = this.removedItemsValue.filter((i) => String(i.value) !== valueStr) + const restoredLabels = this._addCurrentLabel(removed.usage_labels) + this.itemsValue = [...this.itemsValue, { ...removed, usage_labels: restoredLabels }] + return + } + + // Find option data from Tom Select + const option = this.tomSelect.options[value] + if (!option) return + + const usageLabels = this._addCurrentLabel(option.usage_labels || []) + this.itemsValue = [...this.itemsValue, { + id: null, + label: option.text, + value: parseInt(valueStr, 10) || valueStr, + usage_labels: usageLabels + }] + } + + // --- CRUD operations --- + + async createItem (label) { + if (this._busy) return + const t = window.Folio.Input.OrderedMultiselect.t + const existingLabels = this.itemsValue.map((i) => i.label) + + if (window.Folio.Input.OrderedMultiselect.isDuplicateLabel(label, null, existingLabels, this.loadedOptions)) { + window.FolioConsole.Ui.Flash.alert(t('alreadyExists')) + return + } + + this._busy = true + try { + const response = await window.Folio.Api.apiPost(this.createUrlValue, { label }) + const record = response.data + + const usageLabels = this._addCurrentLabel([]) + this.itemsValue = [...this.itemsValue, { + id: null, + label: record.label || record.text, + value: record.id, + usage_labels: usageLabels + }] + + this.resetTomSelect() + } catch (err) { + window.FolioConsole.Ui.Flash.alert(err.message || 'Failed to create record') + } finally { + this._busy = false + } + } + + async renameItem (id, newLabel, isSelectedItem, skipReset) { + if (this._busy) return false + const t = window.Folio.Input.OrderedMultiselect.t + const currentItem = this.itemsValue.find((i) => String(i.value) === String(id)) + const currentLabel = currentItem ? currentItem.label : null + const existingLabels = this.itemsValue.map((i) => i.label) + + if (window.Folio.Input.OrderedMultiselect.isDuplicateLabel(newLabel, currentLabel, existingLabels, this.loadedOptions)) { + window.FolioConsole.Ui.Flash.alert(t('alreadyExists')) + return false + } + + this._busy = true + const url = this.updateUrlValue + + try { + const response = await window.Folio.Api.apiPatch(url, { id, label: newLabel }) + const record = response.data + const updatedLabel = record.label || record.text + + if (isSelectedItem) { + this.itemsValue = this.itemsValue.map((item) => + String(item.value) === String(id) ? { ...item, label: updatedLabel } : item + ) + } + + if (!skipReset) this.resetTomSelect() + return true + } catch (err) { + window.FolioConsole.Ui.Flash.alert(err.message || 'Failed to rename record') + return false + } finally { + this._busy = false + } + } + + async deleteItem (id) { + if (this._busy) return false + const t = window.Folio.Input.OrderedMultiselect.t + const url = this.deleteUrlValue + + this._busy = true + try { + // Check usage first (does not destroy) + const response = await window.Folio.Api.apiDelete(url, { id }) + const data = response.data + + let message + if (data.usage_count > 0 && data.usage_labels && data.usage_labels.length > 0) { + const list = data.usage_labels.map((l) => `- ${l}`).join('\n') + message = t('deleteWarningWithLabels') + .replace('%{count}', data.usage_count) + .replace('%{list}', list) + } else if (data.usage_count > 0) { + message = t('deleteWarning').replace('%{count}', data.usage_count) + } else { + message = t('deleteFromDbConfirm') + } + + if (!window.confirm(message)) return false + + // Confirmed — destroy + await window.Folio.Api.apiDelete(url, { id, confirmed: 'true' }) + + // Remove from items if selected + this.itemsValue = this.itemsValue.filter((i) => String(i.value) !== String(id)) + // Remove from removedItems too + this.removedItemsValue = this.removedItemsValue.filter((i) => String(i.value) !== String(id)) + + return true + } catch (err) { + window.FolioConsole.Ui.Flash.alert(err.message || 'Failed to delete record') + return false + } finally { + this._busy = false + } + } + + // --- Dropdown option actions --- + + onOptionRenameClick (e) { + e.preventDefault() + e.stopPropagation() + + const btn = e.currentTarget + const value = btn.dataset.value + const label = btn.dataset.label + const optionEl = btn.closest('.f-input-ordered-multiselect__option-with-actions') + + if (!optionEl) return + + // Prevent Tom Select from closing the dropdown while editing + this.preventDropdownClose() + + const escape = window.Folio.Input.OrderedMultiselect.escapeHtml + const t = window.Folio.Input.OrderedMultiselect.t + const confirmIcon = window.Folio.Input.OrderedMultiselect.iconHtml('checkbox_marked', { height: 16 }) + + // Get usage_labels from Tom Select option data + const optionData = this.tomSelect ? this.tomSelect.options[value] : null + const usageLabels = optionData ? optionData.usage_labels : [] + const usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml(usageLabels, 'f-input-ordered-multiselect__option-usage', true) + + optionEl.innerHTML = ` + + + ${usageHint} + + + + ${confirmIcon} + + + ` + + const input = optionEl.querySelector('input') + input.focus() + input.select() + } + + onOptionRenameInput (e) { + const input = e.currentTarget + const originalLabel = input.dataset.originalLabel + const existingLabels = this.itemsValue.map((i) => i.label) + const t = window.Folio.Input.OrderedMultiselect.t + + const isDuplicate = window.Folio.Input.OrderedMultiselect.isDuplicateLabel( + input.value, originalLabel, existingLabels, this.loadedOptions + ) + + input.classList.toggle('is-invalid', isDuplicate) + + // Swap usage hint with error message + const labelEl = input.closest('.f-input-ordered-multiselect__option-label') || + input.closest('.f-input-ordered-multiselect__option-with-actions') + if (!labelEl) return + const hintEl = labelEl.querySelector('.f-input-ordered-multiselect__option-usage') + if (hintEl) { + if (isDuplicate) { + if (!hintEl.dataset.originalText) hintEl.dataset.originalText = hintEl.textContent + hintEl.textContent = t('alreadyExists') + hintEl.classList.add('text-danger') + } else { + if (hintEl.dataset.originalText) hintEl.textContent = hintEl.dataset.originalText + hintEl.classList.remove('text-danger') + } + } + } + + async onOptionRenameSubmit (e) { + e.preventDefault() + e.stopPropagation() + + const input = e.currentTarget.tagName === 'INPUT' + ? e.currentTarget + : e.currentTarget.closest('.f-input-ordered-multiselect__option-with-actions').querySelector('.f-input-ordered-multiselect__option-edit-input') + + const value = input.dataset.value + const newLabel = input.value.trim() + const originalLabel = input.dataset.originalLabel + + if (!newLabel || newLabel === originalLabel) { + this.cancelOptionRename(input) + return + } + if (input.classList.contains('is-invalid')) return + + const isSelected = this.itemsValue.some((i) => String(i.value) === String(value)) + const success = await this.renameItem(value, newLabel, isSelected, true) + + if (success) { + // Update Tom Select's internal option data so selection uses the new label + if (this.tomSelect && this.tomSelect.options[value]) { + this.tomSelect.options[value].text = newLabel + } + // Update loadedOptions for duplicate checking + if (this.loadedOptions[String(value)]) { + this.loadedOptions[String(value)].text = newLabel + } + + // Update option label in DOM directly — no need to reload + const optionEl = e.currentTarget.closest('.f-input-ordered-multiselect__option-with-actions') || + e.currentTarget.closest('[data-option-value]') + if (optionEl) { + this.restoreOptionHtml(optionEl, value, newLabel) + + // Flash animation + const parentOption = optionEl.closest('.option') + if (parentOption) { + parentOption.classList.add('f-input-ordered-multiselect__item--flash') + window.setTimeout(() => parentOption.classList.remove('f-input-ordered-multiselect__item--flash'), 600) + } + } + this.restoreDropdownClose(true) + this.refocusTomSelect() + } else { + this.restoreDropdownClose() + } + } + + cancelOptionRename (input) { + if (!input) return + const value = input.dataset.value + const originalLabel = input.dataset.originalLabel + const optionEl = input.closest('.f-input-ordered-multiselect__option-with-actions') + if (optionEl) { + this.restoreOptionHtml(optionEl, value, originalLabel) + } + // Restore close so the dropdown can be dismissed normally (e.g. second Escape) + this.restoreDropdownClose(true) + this.refocusTomSelect() + } + + refocusTomSelect () { + if (!this.tomSelect) return + // Set isFocused synchronously so Tom Select knows it's focused + // before any click events arrive. Then focus the input asynchronously + // with ignoreFocus to prevent refreshOptions from re-rendering the DOM. + this.tomSelect.isFocused = true + this.tomSelect.refreshState() + this.tomSelect.ignoreFocus = true + this.tomSelect.control_input.focus() + window.setTimeout(() => { + if (!this.tomSelect) return + this.tomSelect.ignoreFocus = false + }, 0) + } + + restoreOptionHtml (optionEl, value, label) { + const escape = window.Folio.Input.OrderedMultiselect.escapeHtml + const t = window.Folio.Input.OrderedMultiselect.t + const editIcon = window.Folio.Input.OrderedMultiselect.iconHtml('edit_box', { height: 16 }) + const deleteIcon = window.Folio.Input.OrderedMultiselect.iconHtml('delete', { height: 16 }) + + const optData = this.tomSelect ? this.tomSelect.options[value] : null + const usageLabels = optData ? optData.usage_labels : [] + const usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml(usageLabels, 'f-input-ordered-multiselect__option-usage', true) + + optionEl.innerHTML = ` + + ${escape(label)} + ${usageHint} + + + + + ` + } + + resetTomSelect () { + if (!this.tomSelect) return + this.tomSelect.clear(true) + // Use blur() to properly reset all internal state through Tom Select's normal flow + this.tomSelect.blur() + this._needsReload = true + } + + preventDropdownClose () { + if (!this.tomSelect) return + if (!this._originalClose) { + this._originalClose = this.tomSelect.close.bind(this.tomSelect) + } + this.tomSelect.close = () => {} + + // Remove previous outside-click listener if still attached (prevents orphaned listeners) + if (this._onDocumentMousedown) { + document.removeEventListener('mousedown', this._onDocumentMousedown, true) + } + + // Listen for clicks outside the dropdown to cancel editing and close + this._onDocumentMousedown = (e) => { + if (!this.tomSelect) return + const dropdown = this.tomSelect.dropdown + if (dropdown && !dropdown.contains(e.target)) { + // Cancel any active rename before closing + const activeInput = dropdown.querySelector('.f-input-ordered-multiselect__option-edit-input') + if (activeInput) this.cancelOptionRename(activeInput) + this.restoreDropdownClose() + } + } + window.setTimeout(() => { + document.addEventListener('mousedown', this._onDocumentMousedown, true) + }, 0) + } + + restoreDropdownClose (keepOpen) { + // Remove outside-click listener + if (this._onDocumentMousedown) { + document.removeEventListener('mousedown', this._onDocumentMousedown, true) + this._onDocumentMousedown = null + } + + // Remove any pending click blocker (e.g. rename started via mousedown but confirmed via Enter) + if (this._pendingClickBlocker && this.tomSelect) { + this.tomSelect.dropdown.removeEventListener('click', this._pendingClickBlocker, true) + this._pendingClickBlocker = null + } + + if (!this.tomSelect || !this._originalClose) return + this.tomSelect.close = this._originalClose + this._originalClose = null + + // Always clear value silently to reset Tom Select's internal state + this.tomSelect.clear(true) + + if (keepOpen) return + + this._needsReload = true + this.tomSelect.close() + this.tomSelect.blur() + } + + async onOptionDeleteClick (e) { + e.preventDefault() + e.stopPropagation() + + const btn = e.currentTarget + const value = btn.dataset.value + + // Keep dropdown open during confirm dialogs (they steal focus → Tom Select would close) + this.preventDropdownClose() + + const success = await this.deleteItem(value) + + if (success) { + const t = window.Folio.Input.OrderedMultiselect.t + window.FolioConsole.Ui.Flash.success(t('deleted')) + + // Animate option removal from dropdown + const optionEl = btn.closest('.option') + if (optionEl) { + optionEl.style.transition = 'opacity 0.3s, max-height 0.3s' + optionEl.style.overflow = 'hidden' + optionEl.style.maxHeight = optionEl.offsetHeight + 'px' + optionEl.style.opacity = '0.5' + + window.setTimeout(() => { + optionEl.style.opacity = '0' + optionEl.style.maxHeight = '0' + optionEl.style.padding = '0' + optionEl.style.margin = '0' + }, 100) + + window.setTimeout(() => { + optionEl.remove() + }, 400) + } + + // Restore close but keep dropdown open — user can continue browsing/deleting + this.restoreDropdownClose(true) + } else { + this.restoreDropdownClose() + } + } + + // --- Selected items list --- + + itemsValueChanged () { + if (this._skipRender) return + this.renderList() + this.syncHiddenInputs() + this.dispatchChangeEvent() + } + + renderList () { + if (!this.hasListTarget) return + + const escape = window.Folio.Input.OrderedMultiselect.escapeHtml + const t = window.Folio.Input.OrderedMultiselect.t + const dragIcon = window.Folio.Input.OrderedMultiselect.iconHtml('drag', { height: 24 }) + const sortableHandle = this.sortableValue + ? `${dragIcon}` + : '' + + const editIcon = window.Folio.Input.OrderedMultiselect.iconHtml('edit_box', { height: 16 }) + const closeIcon = window.Folio.Input.OrderedMultiselect.iconHtml('close', { height: 16 }) + + const createableActions = this.createableValue + ? (item) => ` + ` + : () => '' + + const usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml + + this.listTarget.innerHTML = this.itemsValue.map((item) => ` +
+ ${sortableHandle} + + ${escape(item.label)} + ${usageHint(item.usage_labels, 'f-input-ordered-multiselect__item-usage')} + + + ${createableActions(item)} + + +
+ `).join('') + + // Reinitialize sortable after re-render + if (this.sortableValue && this._sortableInitialized) { + this.refreshSortable() + } + } + + syncHiddenInputs () { + if (!this.hasHiddenContainerTarget) return + + const paramBase = this.paramBaseValue + const foreignKey = this.foreignKeyValue + let html = '' + let index = 0 + + // Selected items + this.itemsValue.forEach((item) => { + if (item.id) { + html += `` + } + html += `` + html += `` + index++ + }) + + // Removed items (mark for destruction) + this.removedItemsValue.forEach((item) => { + if (item.id) { + html += `` + html += `` + index++ + } + }) + + this.hiddenContainerTarget.innerHTML = html + } + + dispatchChangeEvent () { + this.element.dispatchEvent(new Event('change', { bubbles: true })) + } + + // --- Item list actions --- + + onItemRemoveClick (e) { + e.preventDefault() + const value = e.currentTarget.dataset.value + const item = this.itemsValue.find((i) => String(i.value) === String(value)) + + if (!item) return + + this.itemsValue = this.itemsValue.filter((i) => String(i.value) !== String(value)) + + // Track for _destroy if it was a persisted record + if (item.id) { + const updatedLabels = this._removeCurrentLabel(item.usage_labels) + this.removedItemsValue = [...this.removedItemsValue, { ...item, usage_labels: updatedLabels }] + } + + // Mark dropdown for reload so the removed item appears again + this._needsReload = true + } + + onItemRenameClick (e) { + e.preventDefault() + + const btn = e.currentTarget + const value = btn.dataset.value + const label = btn.dataset.label + const itemEl = btn.closest('.f-input-ordered-multiselect__item') + + if (!itemEl) return + + const escape = window.Folio.Input.OrderedMultiselect.escapeHtml + const t = window.Folio.Input.OrderedMultiselect.t + const labelEl = itemEl.querySelector('.f-input-ordered-multiselect__item-label') + const actionsEl = itemEl.querySelector('.f-input-ordered-multiselect__item-actions') + const confirmIcon = window.Folio.Input.OrderedMultiselect.iconHtml('checkbox_marked', { height: 16 }) + const item = this.itemsValue.find((i) => String(i.value) === String(value)) + const usageHintStr = window.Folio.Input.OrderedMultiselect.usageHintHtml(item && item.usage_labels, 'f-input-ordered-multiselect__item-usage') + + labelEl.innerHTML = ` +
+ +
+ ${usageHintStr} + ` + + // Replace action buttons with green confirm button + actionsEl.innerHTML = ` + + ${confirmIcon} + + ` + + const input = labelEl.querySelector('input') + const confirmBtn = actionsEl.querySelector('.f-input-ordered-multiselect__item-confirm') + + // Manual event listeners + input.addEventListener('input', (e) => this.onItemRenameInputCheck(e)) + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + this.onItemRenameSubmit(e) + } else if (e.key === 'Escape') { + this.onItemRenameCancel(e) + } + }) + input.addEventListener('blur', (e) => { + // Don't cancel if clicking the confirm button + if (e.relatedTarget && e.relatedTarget.closest('.f-input-ordered-multiselect__item-confirm')) return + this.onItemRenameCancel(e) + }) + + confirmBtn.addEventListener('mousedown', (e) => { + e.preventDefault() // prevent blur on input + }) + confirmBtn.addEventListener('click', (e) => { + e.preventDefault() + this.onItemRenameSubmit({ target: input, preventDefault: () => {}, stopPropagation: () => {} }) + }) + + input.focus() + input.select() + } + + onItemRenameInputCheck (e) { + const input = e.target || e.currentTarget + const originalLabel = input.dataset.originalLabel + const existingLabels = this.itemsValue.map((i) => i.label) + const t = window.Folio.Input.OrderedMultiselect.t + + const isDuplicate = window.Folio.Input.OrderedMultiselect.isDuplicateLabel( + input.value, originalLabel, existingLabels, this.loadedOptions + ) + + input.classList.toggle('is-invalid', isDuplicate) + + // Swap usage hint with error message + const itemLabel = input.closest('.f-input-ordered-multiselect__item-label') + if (!itemLabel) return + const hintEl = itemLabel.querySelector('.f-input-ordered-multiselect__item-usage') + if (hintEl) { + if (isDuplicate) { + if (!hintEl.dataset.originalText) hintEl.dataset.originalText = hintEl.textContent + hintEl.textContent = t('alreadyExists') + hintEl.classList.add('text-danger') + } else { + if (hintEl.dataset.originalText) hintEl.textContent = hintEl.dataset.originalText + hintEl.classList.remove('text-danger') + } + } + } + + async onItemRenameSubmit (e) { + e.preventDefault() + e.stopPropagation() + + const input = e.target.closest('.f-input-ordered-multiselect__item-rename-input') || e.currentTarget + const value = input.dataset.value + const newLabel = input.value.trim() + const originalLabel = input.dataset.originalLabel + + if (!newLabel || newLabel === originalLabel) { + this.renderList() + return + } + + // Check for duplicates at submit time + const existingLabels = this.itemsValue.map((i) => i.label) + if (window.Folio.Input.OrderedMultiselect.isDuplicateLabel(newLabel, originalLabel, existingLabels, this.loadedOptions)) { + input.classList.add('is-invalid') + return + } + + const itemEl = input.closest('.f-input-ordered-multiselect__item') + const success = await this.renameItem(value, newLabel, true) + if (success) { + // Re-render then flash the renamed item + this.renderList() + if (itemEl) { + const newItemEl = this.listTarget.querySelector(`[data-value="${value}"]`) + if (newItemEl) { + newItemEl.classList.add('f-input-ordered-multiselect__item--flash') + window.setTimeout(() => newItemEl.classList.remove('f-input-ordered-multiselect__item--flash'), 600) + } + } + } else { + this.renderList() + } + } + + onItemRenameCancel (e) { + if (e.type === 'blur' && e.relatedTarget && e.relatedTarget.closest('.f-input-ordered-multiselect__item')) { + return + } + this.renderList() + } + + // --- Sortable --- + + initSortable () { + window.Folio.RemoteScripts.run('html5sortable', () => { + if (!this.hasListTarget) return + + window.sortable(this.listTarget, { + items: '.f-input-ordered-multiselect__item', + handle: '.f-input-ordered-multiselect__item-handle', + placeholder: '
' + }) + + this._onSortUpdate = () => this.onSortUpdate() + this.listTarget.addEventListener('sortupdate', this._onSortUpdate) + this._sortableInitialized = true + }) + } + + refreshSortable () { + if (window.sortable && this.hasListTarget) { + window.sortable(this.listTarget) + } + } + + destroySortable () { + if (this._sortableInitialized && window.sortable && this.hasListTarget) { + this.listTarget.removeEventListener('sortupdate', this._onSortUpdate) + window.sortable(this.listTarget, 'destroy') + this._sortableInitialized = false + } + } + + onSortUpdate () { + const itemEls = this.listTarget.querySelectorAll('.f-input-ordered-multiselect__item') + const newOrder = Array.from(itemEls).map((el) => el.dataset.value) + + const reordered = newOrder.map((val) => + this.itemsValue.find((item) => String(item.value) === String(val)) + ).filter(Boolean) + + // Update without triggering re-render (DOM is already in correct order) + this._skipRender = true + try { + this.itemsValue = reordered + } finally { + this._skipRender = false + } + this.syncHiddenInputs() + this.dispatchChangeEvent() + } + + // --- Usage labels helpers --- + + _addCurrentLabel (labels) { + const current = this.currentRecordLabelValue + if (!current) return labels || [] + const arr = labels ? [...labels] : [] + if (!arr.includes(current)) arr.push(current) + return arr + } + + _removeCurrentLabel (labels) { + const current = this.currentRecordLabelValue + if (!current || !labels) return labels || [] + return labels.filter((l) => l !== current) + } +}) diff --git a/app/assets/stylesheets/folio/_input.sass b/app/assets/stylesheets/folio/_input.sass index 958adc5cc7..e300423e2e 100644 --- a/app/assets/stylesheets/folio/_input.sass +++ b/app/assets/stylesheets/folio/_input.sass @@ -3,6 +3,7 @@ @import "./input/date_time" @import "./input/embed" @import "./input/multiselect" +@import "./input/ordered_multiselect" @import "./input/phone" @import "./input/tiptap" @import "./input/url" diff --git a/app/assets/stylesheets/folio/input/_ordered_multiselect.sass b/app/assets/stylesheets/folio/input/_ordered_multiselect.sass new file mode 100644 index 0000000000..e6ba8adb4b --- /dev/null +++ b/app/assets/stylesheets/folio/input/_ordered_multiselect.sass @@ -0,0 +1,152 @@ +.f-input-ordered-multiselect + +font-size($input-font-size) + +border-radius($input-border-radius, 0) + +box-shadow($input-box-shadow) + background: $input-bg + color: $input-color + border: $input-border-width solid $input-border-color + padding: $input-padding-y $input-padding-x + font-family: $input-font-family + font-weight: $input-font-weight + line-height: $input-line-height + min-height: 49px + position: relative + + .form-group-invalid & + border-color: var(--#{$prefix}form-invalid-border-color) + + // Tom Select overrides + .ts-wrapper + border: 0 + + &.focus .ts-control + box-shadow: none !important + border-color: transparent + + .ts-control + border: 0 + box-shadow: none !important + background: transparent + padding: 0 + + > input + &::placeholder + color: $input-placeholder-color + + .ts-dropdown + z-index: 102 + + .no-results + padding: $input-padding-y $input-padding-x + color: $text-muted + cursor: default + pointer-events: none + text-align: center + + // --- Selected items list --- + + &__list + margin-bottom: $grid-gutter-half / 2 + max-height: 300px + overflow-y: auto + -webkit-overflow-scrolling: touch + + &__item + display: flex + align-items: flex-start + padding: 2px 0 + + &--flash + animation: f-input-ordered-multiselect-flash 0.6s ease + + @keyframes f-input-ordered-multiselect-flash + 0% + background-color: rgba($success, 0.2) + 100% + background-color: transparent + + &__item-handle + cursor: grab + opacity: 0.3 + user-select: none + flex-shrink: 0 + display: flex + align-items: center + margin-right: $grid-gutter-half / 2 + + &:hover + opacity: 0.6 + + &__item-label + flex: 1 + min-width: 0 + + &__item-usage, + &__option-usage + display: block + font-size: $font-size-sm + color: $text-muted + white-space: nowrap + overflow: hidden + text-overflow: ellipsis + + &__item-actions, + &__option-actions + display: flex + align-items: center + align-self: stretch + gap: 2px + margin-left: $grid-gutter-half / 2 + flex-shrink: 0 + + &__item-action, + &__option-action + padding: 2px 4px + line-height: 1 + cursor: pointer + display: flex + align-items: center + + &--danger + color: $danger + + &__item-rename-input, + &__option-edit-input + width: 100% + border: 0 + outline: none + background: transparent + font: inherit + padding: 0 + box-shadow: none + + &:focus + box-shadow: none + + // --- Sortable --- + + &__sortable-placeholder + height: 34px + background: $light-gray + border: 1px dashed $medium-gray + border-radius: $border-radius + + // --- Dropdown option with actions --- + + &__option-with-actions + display: flex + align-items: flex-start + width: 100% + + &__option-label + flex: 1 + min-width: 0 + +// Not sortable variant +.f-input-ordered-multiselect--not-sortable + .f-input-ordered-multiselect__item + padding-left: 0 + + .f-input-ordered-multiselect__item-handle + display: none + diff --git a/app/controllers/folio/console/api/autocompletes_controller.rb b/app/controllers/folio/console/api/autocompletes_controller.rb index 6fa96ce972..97c26e7976 100644 --- a/app/controllers/folio/console/api/autocompletes_controller.rb +++ b/app/controllers/folio/console/api/autocompletes_controller.rb @@ -311,13 +311,15 @@ def react_select text = record.to_console_label text = "#{text} – #{record.class.model_name.human}" if show_model_names - { + hash = { id: record.id, text:, label: text, value: Folio::Console::StiHelper.sti_record_to_select_value(record), type: klass.to_s } + hash[:usage_labels] = record.usage_labels_for_warning if record.respond_to?(:usage_labels_for_warning) + hash end render json: { data: response, meta: meta_from_pagy(pagination) } @@ -413,6 +415,89 @@ def react_select end end + def react_select_create + klass = params.require(:class_name).safe_constantize + + if klass && klass < ActiveRecord::Base + record = klass.new + record.title = params.require(:label) + record.site = Folio::Current.site if record.respond_to?(:site=) + authorize! :create, record + + if record.save + render json: { + data: { + id: record.id, + text: record.to_console_label, + label: record.to_console_label, + value: record.id.to_s, + type: record.class.to_s + } + } + else + render json: { error: record.errors.full_messages.join(", ") }, status: :unprocessable_entity + end + else + render json: { error: "Invalid class" }, status: :unprocessable_entity + end + end + + def react_select_update + klass = params.require(:class_name).safe_constantize + id = params.require(:id) + label = params.require(:label) + + if klass && klass < ActiveRecord::Base + record = klass.find(id) + authorize! :update, record + record.title = label + + if record.save + render json: { + data: { + id: record.id, + text: record.to_console_label, + label: record.to_console_label, + value: record.id.to_s, + type: record.class.to_s + } + } + else + render json: { error: record.errors.full_messages.join(", ") }, status: :unprocessable_entity + end + else + render json: { error: "Invalid class" }, status: :unprocessable_entity + end + end + + def react_select_destroy + klass = params.require(:class_name).safe_constantize + id = params.require(:id) + + if klass && klass < ActiveRecord::Base + record = klass.find(id) + authorize! :destroy, record + + usage_count = record.respond_to?(:usage_count_for_warning) ? record.usage_count_for_warning : 0 + usage_labels = record.respond_to?(:usage_labels_for_warning) ? record.usage_labels_for_warning : [] + + if params[:confirmed] == "true" + record.destroy! + render json: { data: { id: record.id, destroyed: true } } + else + render json: { + data: { + id: record.id, + usage_count: usage_count, + usage_labels: usage_labels, + } + } + end + else + render json: { error: "Invalid class" }, status: :unprocessable_entity + end + end + private def apply_ordered_for_folio_console_selects(scope, klass) return [scope, false] unless klass.respond_to?(:ordered_for_folio_console_selects) diff --git a/app/helpers/folio/console/react_helper.rb b/app/helpers/folio/console/react_helper.rb index 41a9415cd6..22bc2c865f 100644 --- a/app/helpers/folio/console/react_helper.rb +++ b/app/helpers/folio/console/react_helper.rb @@ -223,6 +223,132 @@ def react_ordered_multiselect(f, end end + def stimulus_ordered_multiselect(f, + relation_name, + scope: nil, + order_scope: :ordered, + sortable: true, + createable: false, + required: nil, + create_url: nil, + update_url: nil, + delete_url: nil) + class_name = "f-input-ordered-multiselect" + class_name += " f-input-ordered-multiselect--not-sortable" unless sortable + + klass = f.object.class + reflection = klass.reflections[relation_name.to_s] + through = reflection.options[:through] + + if through.nil? + raise StandardError, "Only supported for :through relations" + end + + through_klass = reflection.class_name.constantize + param_base = "#{f.object_name}[#{through}_attributes]" + + items = [] + removed_items = [] + + f.object.send(through).each do |record| + through_record = through_klass.find(record.send(reflection.foreign_key)) + hash = { + id: record.id, + label: through_record.to_console_label, + value: through_record.id, + _destroy: record.marked_for_destruction?, + usage_labels: through_record.respond_to?(:usage_labels_for_warning) ? through_record.usage_labels_for_warning : [], + } + + if hash[:_destroy] + removed_items << hash if hash[:id] + else + items << hash + end + end + + url = Folio::Engine.routes.url_helpers.url_for([ + :react_select, + :console, + :api, + :autocomplete, + { + class_names: through_klass.to_s, + scope: scope, + order_scope: order_scope, + only_path: true + } + ]) + + current_record_label = f.object.respond_to?(:to_console_label) ? f.object.to_console_label : f.object.try(:title) || "" + + data = { + "controller" => "f-input-ordered-multiselect", + "f-input-ordered-multiselect-items-value" => items.to_json, + "f-input-ordered-multiselect-removed-items-value" => removed_items.to_json, + "f-input-ordered-multiselect-url-value" => url, + "f-input-ordered-multiselect-param-base-value" => param_base, + "f-input-ordered-multiselect-foreign-key-value" => reflection.foreign_key, + "f-input-ordered-multiselect-sortable-value" => sortable ? "true" : "false", + "f-input-ordered-multiselect-createable-value" => createable ? "true" : "false", + "f-input-ordered-multiselect-current-record-label-value" => current_record_label, + } + + if createable + create_path = create_url || Folio::Engine.routes.url_helpers.url_for([ + :react_select_create, + :console, + :api, + :autocomplete, + { class_name: through_klass.to_s, only_path: true } + ]) + + update_path = update_url || Folio::Engine.routes.url_helpers.url_for([ + :react_select_update, + :console, + :api, + :autocomplete, + { class_name: through_klass.to_s, only_path: true } + ]) + + delete_path = delete_url || Folio::Engine.routes.url_helpers.url_for([ + :react_select_destroy, + :console, + :api, + :autocomplete, + { class_name: through_klass.to_s, only_path: true } + ]) + + data["f-input-ordered-multiselect-create-url-value"] = create_path + data["f-input-ordered-multiselect-update-url-value"] = update_path + data["f-input-ordered-multiselect-delete-url-value"] = delete_path + end + + form_group_class_name = if f.object.errors[relation_name].present? + "form-group form-group-invalid" + else + "form-group" + end + + content_tag(:div, class: form_group_class_name) do + concat(f.label(relation_name, required: required)) + concat( + content_tag(:div, data: data, class: "#{class_name} form-control") do + concat(content_tag(:div, nil, + class: "f-input-ordered-multiselect__list", + "data-f-input-ordered-multiselect-target" => "list")) + concat(content_tag(:select, nil, + class: "f-input-ordered-multiselect__select", + "data-f-input-ordered-multiselect-target" => "select", + multiple: false)) + concat(content_tag(:div, nil, + "data-f-input-ordered-multiselect-target" => "hiddenContainer")) + end + ) + concat(f.full_error(relation_name, class: "invalid-feedback d-block")) + end + end + REACT_NOTE_PARENT_CLASS_NAME = "f-c-r-notes-fields-app-parent" REACT_NOTE_TOOLTIP_PARENT_CLASS_NAME = "f-c-r-notes-fields-app-tooltip-parent" REACT_NOTE_FORM_PARENT_CLASS_NAME = "f-c-r-notes-fields-app-form-parent" diff --git a/config/routes.rb b/config/routes.rb index bd8b6e79a3..9166e3b3de 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -275,6 +275,9 @@ get :selectize get :select2 get :react_select + post :react_select_create + patch :react_select_update + delete :react_select_destroy end resources :file_placements, only: %i[index], From f412d2c2ef038b4a25c988fcdb3e05cc7dbfc876 Mon Sep 17 00:00:00 2001 From: Viktor <38672169+vdedek@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:17:44 +0100 Subject: [PATCH 2/5] fix: empty dropdown + real-time usage labels + delete confirm with local state - __no_results__ dummy option keeps dropdown open when async load returns empty - Usage labels overlay current record label based on form selection state - Delete confirm dialog prefers local loadedOptions over server response - Dropdown closes after deleting the last remaining option - onItemRemoveClick syncs loadedOptions usage_labels --- .../folio/input/ordered_multiselect.js | 66 +++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/folio/input/ordered_multiselect.js b/app/assets/javascripts/folio/input/ordered_multiselect.js index aaed42639c..342d47bd23 100644 --- a/app/assets/javascripts/folio/input/ordered_multiselect.js +++ b/app/assets/javascripts/folio/input/ordered_multiselect.js @@ -172,10 +172,30 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind const selectedIds = self.itemsValue.map((i) => String(i.value)) const filtered = data.filter((d) => !selectedIds.includes(d.value)) - // Accumulate loaded options for duplicate detection - filtered.forEach((opt) => { self.loadedOptions[opt.value] = opt }) - - callback(filtered) + // Accumulate loaded options for duplicate detection. + // Overlay current record label based on actual selection state — + // server data doesn't reflect unsaved form changes. + const currentLabel = self.currentRecordLabelValue + filtered.forEach((opt) => { + if (currentLabel) { + const isSelected = self.itemsValue.some((i) => String(i.value) === opt.value) + if (isSelected && !opt.usage_labels.includes(currentLabel)) { + opt.usage_labels = [...opt.usage_labels, currentLabel] + } else if (!isSelected) { + opt.usage_labels = opt.usage_labels.filter((l) => l !== currentLabel) + } + } + self.loadedOptions[opt.value] = opt + }) + + // Tom Select closes the dropdown when async load returns empty array + // (no_results renderer only fires for local filtering, not async load). + // Inject a non-selectable dummy option so the dropdown stays open. + if (filtered.length === 0) { + callback([{ value: '__no_results__', text: t('noResults'), disabled: true }]) + } else { + callback(filtered) + } }) .catch(() => { callback() @@ -184,6 +204,9 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind render: { option (data, escape) { + if (data.value === '__no_results__') { + return `
${escape(data.text)}
` + } if (self.createableValue) { return self.renderOptionWithActions(data, escape) } @@ -212,7 +235,7 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind }, onChange (value) { - if (!value) return + if (!value || value === '__no_results__') return self.onItemSelected(value) // Reset Tom Select — clear value, keep options so dropdown can reopen window.setTimeout(() => { @@ -486,14 +509,19 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind const response = await window.Folio.Api.apiDelete(url, { id }) const data = response.data + // Prefer local usage_labels (reflects unsaved form changes) over server-side data + const localOpt = this.loadedOptions[String(id)] + const usageLabels = localOpt ? (localOpt.usage_labels || []) : (data.usage_labels || []) + const usageCount = usageLabels.length + let message - if (data.usage_count > 0 && data.usage_labels && data.usage_labels.length > 0) { - const list = data.usage_labels.map((l) => `- ${l}`).join('\n') + if (usageCount > 0 && usageLabels.length > 0) { + const list = usageLabels.map((l) => `- ${l}`).join('\n') message = t('deleteWarningWithLabels') - .replace('%{count}', data.usage_count) + .replace('%{count}', usageCount) .replace('%{list}', list) - } else if (data.usage_count > 0) { - message = t('deleteWarning').replace('%{count}', data.usage_count) + } else if (usageCount > 0) { + message = t('deleteWarning').replace('%{count}', usageCount) } else { message = t('deleteFromDbConfirm') } @@ -802,8 +830,16 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind }, 400) } - // Restore close but keep dropdown open — user can continue browsing/deleting - this.restoreDropdownClose(true) + // Check if any real options remain after this deletion + const allOptions = this.tomSelect.dropdown.querySelectorAll('.option') + const realRemaining = Array.from(allOptions).filter((el) => el !== optionEl) + if (realRemaining.length === 0) { + // No options left — close dropdown completely + this.restoreDropdownClose() + } else { + // More options remain — keep dropdown open for further browsing/deleting + this.restoreDropdownClose(true) + } } else { this.restoreDropdownClose() } @@ -920,6 +956,12 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind this.removedItemsValue = [...this.removedItemsValue, { ...item, usage_labels: updatedLabels }] } + // Update loadedOptions so dropdown shows updated usage_labels + const valStr = String(value) + if (this.loadedOptions[valStr]) { + this.loadedOptions[valStr].usage_labels = this._removeCurrentLabel(this.loadedOptions[valStr].usage_labels) + } + // Mark dropdown for reload so the removed item appears again this._needsReload = true } From f6ac5fbf026757a089eee86bee22ff0aed58808b Mon Sep 17 00:00:00 2001 From: Viktor <38672169+vdedek@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:23:35 +0100 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20condense=20OrderedMultiselect?= =?UTF-8?q?=20controller=20(1187=20=E2=86=92=20747=20lines)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical refactoring — no behavior changes: - Merge renderOptionWithActions + restoreOptionHtml into shared optionActionHtml - Unify onOptionRenameInput + onItemRenameInputCheck into shared checkRenameDuplicate - Extract fakeEvent helper in bindDropdownEvents - Condense verbose HTML templates and remove redundant blank lines/comments - Keep all section comments for readability --- .../folio/input/ordered_multiselect.js | 732 ++++-------------- 1 file changed, 152 insertions(+), 580 deletions(-) diff --git a/app/assets/javascripts/folio/input/ordered_multiselect.js b/app/assets/javascripts/folio/input/ordered_multiselect.js index 342d47bd23..569826a00e 100644 --- a/app/assets/javascripts/folio/input/ordered_multiselect.js +++ b/app/assets/javascripts/folio/input/ordered_multiselect.js @@ -109,25 +109,18 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind connect () { this.loadedOptions = {} this.initTomSelect() - - if (this.sortableValue) { - this.initSortable() - } - + if (this.sortableValue) this.initSortable() this.renderList() this.syncHiddenInputs() } disconnect () { - if (this._onDocumentMousedown) { - document.removeEventListener('mousedown', this._onDocumentMousedown, true) - this._onDocumentMousedown = null - } + if (this._onDocumentMousedown) { document.removeEventListener('mousedown', this._onDocumentMousedown, true); this._onDocumentMousedown = null } this.destroyTomSelect() this.destroySortable() } - // --- Tom Select --- + // --- Tom Select setup --- initTomSelect () { const self = this @@ -153,26 +146,15 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind closeAfterSelect: true, load (query, callback) { - const separator = self.urlValue.includes('?') ? '&' : '?' - const url = `${self.urlValue}${separator}q=${encodeURIComponent(query)}` - - fetch(url, { - headers: window.Folio.Api.JSON_HEADERS, - credentials: 'same-origin' - }) + const url = `${self.urlValue}${self.urlValue.includes('?') ? '&' : '?'}q=${encodeURIComponent(query)}` + fetch(url, { headers: window.Folio.Api.JSON_HEADERS, credentials: 'same-origin' }) .then((r) => r.json()) .then((json) => { - const data = (json.data || []).map((item) => ({ - value: String(item.id), - text: item.label || item.text, - usage_labels: item.usage_labels || [] - })) - - // Filter out already selected items const selectedIds = self.itemsValue.map((i) => String(i.value)) - const filtered = data.filter((d) => !selectedIds.includes(d.value)) + const filtered = (json.data || []) + .map((item) => ({ value: String(item.id), text: item.label || item.text, usage_labels: item.usage_labels || [] })) + .filter((d) => !selectedIds.includes(d.value)) - // Accumulate loaded options for duplicate detection. // Overlay current record label based on actual selection state — // server data doesn't reflect unsaved form changes. const currentLabel = self.currentRecordLabelValue @@ -197,82 +179,39 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind callback(filtered) } }) - .catch(() => { - callback() - }) + .catch(() => callback()) }, - render: { option (data, escape) { - if (data.value === '__no_results__') { - return `
${escape(data.text)}
` - } - if (self.createableValue) { - return self.renderOptionWithActions(data, escape) - } - return `
${escape(data.text)}
` + if (data.value === '__no_results__') return `
${escape(data.text)}
` + return self.createableValue ? self.renderOptionWithActions(data, escape) : `
${escape(data.text)}
` }, - option_create (data, escape) { - return `
- ${t('create')} ${escape(data.input)}… -
` + return `
${t('create')} ${escape(data.input)}
` }, - no_results (data) { - if (data.input) { - const existingLabels = self.itemsValue.map((i) => i.label) - if (existingLabels.some((l) => l.toLowerCase().trim() === data.input.toLowerCase().trim())) { - return `
${t('alreadyOnList')}
` - } + if (data.input && self.itemsValue.some((i) => i.label.toLowerCase().trim() === data.input.toLowerCase().trim())) { + return `
${t('alreadyOnList')}
` } return `
${t('noResults')}
` }, - - loading () { - return `
` - } + loading () { return `
` } }, - onChange (value) { if (!value || value === '__no_results__') return self.onItemSelected(value) - // Reset Tom Select — clear value, keep options so dropdown can reopen - window.setTimeout(() => { - if (self.tomSelect) { - self.tomSelect.clear(true) - self._needsReload = true - } - }, 0) + window.setTimeout(() => { if (self.tomSelect) { self.tomSelect.clear(true); self._needsReload = true } }, 0) } } - if (this.createableValue) { - config.create = (input, callback) => { - // Don't add to Tom Select's internal state — we handle it via API - self.createItem(input) - callback() - } - - config.createFilter = (input) => { - const existingLabels = self.itemsValue.map((i) => i.label) - return !window.Folio.Input.OrderedMultiselect.isDuplicateLabel( - input, null, existingLabels, self.loadedOptions - ) - } + config.create = (input, callback) => { self.createItem(input); callback() } + config.createFilter = (input) => !window.Folio.Input.OrderedMultiselect.isDuplicateLabel(input, null, self.itemsValue.map((i) => i.label), self.loadedOptions) } - this.tomSelect = new window.TomSelect(this.selectTarget, config) - this.tomSelect.on('dropdown_open', () => { - if (this._needsReload) { - this._needsReload = false - this.tomSelect.clearOptions() - this.tomSelect.load('') - } + if (this._needsReload) { this._needsReload = false; this.tomSelect.clearOptions(); this.tomSelect.load('') } this.adjustDropdownMaxHeight() }) - this.bindDropdownEvents() } @@ -280,75 +219,46 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind if (!this.tomSelect) return const content = this.tomSelect.dropdown.querySelector('.ts-dropdown-content') if (!content) return - const footer = document.querySelector('.f-c-form-footer') const bottomLimit = footer ? footer.getBoundingClientRect().top : window.innerHeight - const dropdownTop = this.tomSelect.dropdown.getBoundingClientRect().top - const available = bottomLimit - dropdownTop - 10 - - content.style.maxHeight = Math.max(available, 80) + 'px' + content.style.maxHeight = Math.max(bottomLimit - this.tomSelect.dropdown.getBoundingClientRect().top - 10, 80) + 'px' } destroyTomSelect () { this.unbindDropdownEvents() - if (this.tomSelect) { - this.tomSelect.destroy() - delete this.tomSelect - } + if (this.tomSelect) { this.tomSelect.destroy(); delete this.tomSelect } } bindDropdownEvents () { if (!this.tomSelect) return const dropdown = this.tomSelect.dropdown - + const noop = () => {} + const fakeEvent = (target) => ({ currentTarget: target, preventDefault: noop, stopPropagation: noop }) this._onDropdownMousedown = (e) => { const submitBtn = e.target.closest('.f-input-ordered-multiselect__option-confirm') const deleteBtn = e.target.closest('.f-input-ordered-multiselect__option-action--danger') const renameBtn = e.target.closest('.f-input-ordered-multiselect__option-action:not(.f-input-ordered-multiselect__option-action--danger):not(.f-input-ordered-multiselect__option-confirm)') - if (submitBtn || deleteBtn || renameBtn) { e.preventDefault() e.stopPropagation() - // Tom Select selects options on 'click' (not mousedown). - // Block the subsequent click so it doesn't trigger option selection. - // Store reference so it can be removed if rename is confirmed via Enter (no click follows). - if (this._pendingClickBlocker) { - dropdown.removeEventListener('click', this._pendingClickBlocker, true) - } + if (this._pendingClickBlocker) dropdown.removeEventListener('click', this._pendingClickBlocker, true) this._pendingClickBlocker = (ce) => { ce.preventDefault(); ce.stopPropagation(); this._pendingClickBlocker = null } dropdown.addEventListener('click', this._pendingClickBlocker, { capture: true, once: true }) } - - if (submitBtn) { - this.onOptionRenameSubmit({ currentTarget: submitBtn, preventDefault: () => {}, stopPropagation: () => {} }) - } else if (deleteBtn) { - this.onOptionDeleteClick({ currentTarget: deleteBtn, preventDefault: () => {}, stopPropagation: () => {} }) - } else if (renameBtn) { - this.onOptionRenameClick({ currentTarget: renameBtn, preventDefault: () => {}, stopPropagation: () => {} }) - } + if (submitBtn) this.onOptionRenameSubmit(fakeEvent(submitBtn)) + else if (deleteBtn) this.onOptionDeleteClick(fakeEvent(deleteBtn)) + else if (renameBtn) this.onOptionRenameClick(fakeEvent(renameBtn)) } - this._onDropdownKeydown = (e) => { const input = e.target.closest('.f-input-ordered-multiselect__option-edit-input') if (!input) return - - if (e.key === 'Enter') { - e.preventDefault() - e.stopPropagation() - this.onOptionRenameSubmit({ currentTarget: input, preventDefault: () => {}, stopPropagation: () => {} }) - } else if (e.key === 'Escape') { - e.preventDefault() - e.stopPropagation() - this.cancelOptionRename(input) - } + if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); this.onOptionRenameSubmit(fakeEvent(input)) } + else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); this.cancelOptionRename(input) } } - this._onDropdownInput = (e) => { const input = e.target.closest('.f-input-ordered-multiselect__option-edit-input') - if (!input) return - this.onOptionRenameInput({ currentTarget: input }) + if (input) this.onOptionRenameInput({ currentTarget: input }) } - dropdown.addEventListener('mousedown', this._onDropdownMousedown, true) dropdown.addEventListener('keydown', this._onDropdownKeydown, true) dropdown.addEventListener('input', this._onDropdownInput, true) @@ -356,104 +266,71 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind unbindDropdownEvents () { if (!this.tomSelect) return - const dropdown = this.tomSelect.dropdown - if (this._onDropdownMousedown) dropdown.removeEventListener('mousedown', this._onDropdownMousedown, true) - if (this._onDropdownKeydown) dropdown.removeEventListener('keydown', this._onDropdownKeydown, true) - if (this._onDropdownInput) dropdown.removeEventListener('input', this._onDropdownInput, true) + const dd = this.tomSelect.dropdown + if (this._onDropdownMousedown) dd.removeEventListener('mousedown', this._onDropdownMousedown, true) + if (this._onDropdownKeydown) dd.removeEventListener('keydown', this._onDropdownKeydown, true) + if (this._onDropdownInput) dd.removeEventListener('input', this._onDropdownInput, true) } - renderOptionWithActions (data, escape) { - // Don't add actions to "create new" options - if (String(data.value).startsWith('__create__')) { - return `
${escape(data.text)}
` - } + // --- Shared option HTML (used by renderOptionWithActions + restoreOptionHtml) --- + optionActionHtml (value, label, usageLabels) { + const escape = window.Folio.Input.OrderedMultiselect.escapeHtml const t = window.Folio.Input.OrderedMultiselect.t - const editIcon = window.Folio.Input.OrderedMultiselect.iconHtml('edit_box', { height: 16 }) const deleteIcon = window.Folio.Input.OrderedMultiselect.iconHtml('delete', { height: 16 }) - const usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml(data.usage_labels, 'f-input-ordered-multiselect__option-usage', true) - - return `
- - ${escape(data.text)} - ${usageHint} - + const usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml(usageLabels, 'f-input-ordered-multiselect__option-usage', true) + return `${escape(label)}${usageHint} - - - -
` + + + ` + } + + renderOptionWithActions (data, escape) { + if (String(data.value).startsWith('__create__')) return `
${escape(data.text)}
` + return `
${this.optionActionHtml(data.value, data.text, data.usage_labels)}
` } - // --- Item selection --- + restoreOptionHtml (optionEl, value, label) { + const optData = this.tomSelect ? this.tomSelect.options[value] : null + optionEl.innerHTML = this.optionActionHtml(value, label, optData ? optData.usage_labels : []) + } + + // --- Item selection & CRUD --- onItemSelected (value) { const valueStr = String(value) - - // Skip create values — handled directly in Tom Select create callback if (valueStr.startsWith('__create__')) return - - // Check if already selected if (this.itemsValue.find((i) => String(i.value) === valueStr)) return - - // Check if was previously removed — restore it const removed = this.removedItemsValue.find((i) => String(i.value) === valueStr) if (removed) { this.removedItemsValue = this.removedItemsValue.filter((i) => String(i.value) !== valueStr) - const restoredLabels = this._addCurrentLabel(removed.usage_labels) - this.itemsValue = [...this.itemsValue, { ...removed, usage_labels: restoredLabels }] + this.itemsValue = [...this.itemsValue, { ...removed, usage_labels: this._addCurrentLabel(removed.usage_labels) }] return } - - // Find option data from Tom Select const option = this.tomSelect.options[value] if (!option) return - - const usageLabels = this._addCurrentLabel(option.usage_labels || []) this.itemsValue = [...this.itemsValue, { - id: null, - label: option.text, - value: parseInt(valueStr, 10) || valueStr, - usage_labels: usageLabels + id: null, label: option.text, value: parseInt(valueStr, 10) || valueStr, + usage_labels: this._addCurrentLabel(option.usage_labels || []) }] } - // --- CRUD operations --- - async createItem (label) { if (this._busy) return const t = window.Folio.Input.OrderedMultiselect.t - const existingLabels = this.itemsValue.map((i) => i.label) - - if (window.Folio.Input.OrderedMultiselect.isDuplicateLabel(label, null, existingLabels, this.loadedOptions)) { + if (window.Folio.Input.OrderedMultiselect.isDuplicateLabel(label, null, this.itemsValue.map((i) => i.label), this.loadedOptions)) { window.FolioConsole.Ui.Flash.alert(t('alreadyExists')) return } - this._busy = true try { - const response = await window.Folio.Api.apiPost(this.createUrlValue, { label }) - const record = response.data - - const usageLabels = this._addCurrentLabel([]) + const record = (await window.Folio.Api.apiPost(this.createUrlValue, { label })).data this.itemsValue = [...this.itemsValue, { - id: null, - label: record.label || record.text, - value: record.id, - usage_labels: usageLabels + id: null, label: record.label || record.text, value: record.id, + usage_labels: this._addCurrentLabel([]) }] - this.resetTomSelect() } catch (err) { window.FolioConsole.Ui.Flash.alert(err.message || 'Failed to create record') @@ -466,28 +343,18 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind if (this._busy) return false const t = window.Folio.Input.OrderedMultiselect.t const currentItem = this.itemsValue.find((i) => String(i.value) === String(id)) - const currentLabel = currentItem ? currentItem.label : null - const existingLabels = this.itemsValue.map((i) => i.label) - - if (window.Folio.Input.OrderedMultiselect.isDuplicateLabel(newLabel, currentLabel, existingLabels, this.loadedOptions)) { + if (window.Folio.Input.OrderedMultiselect.isDuplicateLabel(newLabel, currentItem ? currentItem.label : null, this.itemsValue.map((i) => i.label), this.loadedOptions)) { window.FolioConsole.Ui.Flash.alert(t('alreadyExists')) return false } - this._busy = true - const url = this.updateUrlValue - try { - const response = await window.Folio.Api.apiPatch(url, { id, label: newLabel }) - const record = response.data - const updatedLabel = record.label || record.text - + const updatedLabel = ((await window.Folio.Api.apiPatch(this.updateUrlValue, { id, label: newLabel })).data).label || newLabel if (isSelectedItem) { this.itemsValue = this.itemsValue.map((item) => String(item.value) === String(id) ? { ...item, label: updatedLabel } : item ) } - if (!skipReset) this.resetTomSelect() return true } catch (err) { @@ -501,41 +368,25 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind async deleteItem (id) { if (this._busy) return false const t = window.Folio.Input.OrderedMultiselect.t - const url = this.deleteUrlValue - this._busy = true try { - // Check usage first (does not destroy) - const response = await window.Folio.Api.apiDelete(url, { id }) - const data = response.data - + const data = (await window.Folio.Api.apiDelete(this.deleteUrlValue, { id })).data // Prefer local usage_labels (reflects unsaved form changes) over server-side data const localOpt = this.loadedOptions[String(id)] const usageLabels = localOpt ? (localOpt.usage_labels || []) : (data.usage_labels || []) const usageCount = usageLabels.length - let message if (usageCount > 0 && usageLabels.length > 0) { - const list = usageLabels.map((l) => `- ${l}`).join('\n') - message = t('deleteWarningWithLabels') - .replace('%{count}', usageCount) - .replace('%{list}', list) + message = t('deleteWarningWithLabels').replace('%{count}', usageCount).replace('%{list}', usageLabels.map((l) => `- ${l}`).join('\n')) } else if (usageCount > 0) { message = t('deleteWarning').replace('%{count}', usageCount) } else { message = t('deleteFromDbConfirm') } - if (!window.confirm(message)) return false - - // Confirmed — destroy - await window.Folio.Api.apiDelete(url, { id, confirmed: 'true' }) - - // Remove from items if selected + await window.Folio.Api.apiDelete(this.deleteUrlValue, { id, confirmed: 'true' }) this.itemsValue = this.itemsValue.filter((i) => String(i.value) !== String(id)) - // Remove from removedItems too this.removedItemsValue = this.removedItemsValue.filter((i) => String(i.value) !== String(id)) - return true } catch (err) { window.FolioConsole.Ui.Flash.alert(err.message || 'Failed to delete record') @@ -545,120 +396,54 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind } } - // --- Dropdown option actions --- + // --- Dropdown option inline rename & delete --- onOptionRenameClick (e) { e.preventDefault() e.stopPropagation() - const btn = e.currentTarget const value = btn.dataset.value const label = btn.dataset.label const optionEl = btn.closest('.f-input-ordered-multiselect__option-with-actions') - if (!optionEl) return - - // Prevent Tom Select from closing the dropdown while editing this.preventDropdownClose() - const escape = window.Folio.Input.OrderedMultiselect.escapeHtml - const t = window.Folio.Input.OrderedMultiselect.t const confirmIcon = window.Folio.Input.OrderedMultiselect.iconHtml('checkbox_marked', { height: 16 }) - - // Get usage_labels from Tom Select option data const optionData = this.tomSelect ? this.tomSelect.options[value] : null - const usageLabels = optionData ? optionData.usage_labels : [] - const usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml(usageLabels, 'f-input-ordered-multiselect__option-usage', true) - - optionEl.innerHTML = ` - - + const usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml(optionData ? optionData.usage_labels : [], 'f-input-ordered-multiselect__option-usage', true) + optionEl.innerHTML = ` + ${usageHint} - - ${confirmIcon} - - - ` - + ${confirmIcon} + ` const input = optionEl.querySelector('input') input.focus() input.select() } onOptionRenameInput (e) { - const input = e.currentTarget - const originalLabel = input.dataset.originalLabel - const existingLabels = this.itemsValue.map((i) => i.label) - const t = window.Folio.Input.OrderedMultiselect.t - - const isDuplicate = window.Folio.Input.OrderedMultiselect.isDuplicateLabel( - input.value, originalLabel, existingLabels, this.loadedOptions - ) - - input.classList.toggle('is-invalid', isDuplicate) - - // Swap usage hint with error message - const labelEl = input.closest('.f-input-ordered-multiselect__option-label') || - input.closest('.f-input-ordered-multiselect__option-with-actions') - if (!labelEl) return - const hintEl = labelEl.querySelector('.f-input-ordered-multiselect__option-usage') - if (hintEl) { - if (isDuplicate) { - if (!hintEl.dataset.originalText) hintEl.dataset.originalText = hintEl.textContent - hintEl.textContent = t('alreadyExists') - hintEl.classList.add('text-danger') - } else { - if (hintEl.dataset.originalText) hintEl.textContent = hintEl.dataset.originalText - hintEl.classList.remove('text-danger') - } - } + this.checkRenameDuplicate(e.currentTarget, '.f-input-ordered-multiselect__option-label, .f-input-ordered-multiselect__option-with-actions', '.f-input-ordered-multiselect__option-usage') } async onOptionRenameSubmit (e) { e.preventDefault() e.stopPropagation() - const input = e.currentTarget.tagName === 'INPUT' ? e.currentTarget : e.currentTarget.closest('.f-input-ordered-multiselect__option-with-actions').querySelector('.f-input-ordered-multiselect__option-edit-input') - const value = input.dataset.value const newLabel = input.value.trim() - const originalLabel = input.dataset.originalLabel - - if (!newLabel || newLabel === originalLabel) { - this.cancelOptionRename(input) - return - } + if (!newLabel || newLabel === input.dataset.originalLabel) { this.cancelOptionRename(input); return } if (input.classList.contains('is-invalid')) return - - const isSelected = this.itemsValue.some((i) => String(i.value) === String(value)) - const success = await this.renameItem(value, newLabel, isSelected, true) - + const success = await this.renameItem(value, newLabel, this.itemsValue.some((i) => String(i.value) === String(value)), true) if (success) { - // Update Tom Select's internal option data so selection uses the new label - if (this.tomSelect && this.tomSelect.options[value]) { - this.tomSelect.options[value].text = newLabel - } - // Update loadedOptions for duplicate checking - if (this.loadedOptions[String(value)]) { - this.loadedOptions[String(value)].text = newLabel - } - - // Update option label in DOM directly — no need to reload - const optionEl = e.currentTarget.closest('.f-input-ordered-multiselect__option-with-actions') || - e.currentTarget.closest('[data-option-value]') + if (this.tomSelect && this.tomSelect.options[value]) this.tomSelect.options[value].text = newLabel + if (this.loadedOptions[String(value)]) this.loadedOptions[String(value)].text = newLabel + const optionEl = e.currentTarget.closest('.f-input-ordered-multiselect__option-with-actions') || e.currentTarget.closest('[data-option-value]') if (optionEl) { this.restoreOptionHtml(optionEl, value, newLabel) - - // Flash animation const parentOption = optionEl.closest('.option') if (parentOption) { parentOption.classList.add('f-input-ordered-multiselect__item--flash') @@ -674,121 +459,53 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind cancelOptionRename (input) { if (!input) return - const value = input.dataset.value - const originalLabel = input.dataset.originalLabel const optionEl = input.closest('.f-input-ordered-multiselect__option-with-actions') - if (optionEl) { - this.restoreOptionHtml(optionEl, value, originalLabel) - } - // Restore close so the dropdown can be dismissed normally (e.g. second Escape) + if (optionEl) this.restoreOptionHtml(optionEl, input.dataset.value, input.dataset.originalLabel) this.restoreDropdownClose(true) this.refocusTomSelect() } refocusTomSelect () { if (!this.tomSelect) return - // Set isFocused synchronously so Tom Select knows it's focused - // before any click events arrive. Then focus the input asynchronously - // with ignoreFocus to prevent refreshOptions from re-rendering the DOM. this.tomSelect.isFocused = true this.tomSelect.refreshState() this.tomSelect.ignoreFocus = true this.tomSelect.control_input.focus() - window.setTimeout(() => { - if (!this.tomSelect) return - this.tomSelect.ignoreFocus = false - }, 0) - } - - restoreOptionHtml (optionEl, value, label) { - const escape = window.Folio.Input.OrderedMultiselect.escapeHtml - const t = window.Folio.Input.OrderedMultiselect.t - const editIcon = window.Folio.Input.OrderedMultiselect.iconHtml('edit_box', { height: 16 }) - const deleteIcon = window.Folio.Input.OrderedMultiselect.iconHtml('delete', { height: 16 }) - - const optData = this.tomSelect ? this.tomSelect.options[value] : null - const usageLabels = optData ? optData.usage_labels : [] - const usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml(usageLabels, 'f-input-ordered-multiselect__option-usage', true) - - optionEl.innerHTML = ` - - ${escape(label)} - ${usageHint} - - - - - ` + window.setTimeout(() => { if (this.tomSelect) this.tomSelect.ignoreFocus = false }, 0) } resetTomSelect () { if (!this.tomSelect) return this.tomSelect.clear(true) - // Use blur() to properly reset all internal state through Tom Select's normal flow this.tomSelect.blur() this._needsReload = true } preventDropdownClose () { if (!this.tomSelect) return - if (!this._originalClose) { - this._originalClose = this.tomSelect.close.bind(this.tomSelect) - } + if (!this._originalClose) this._originalClose = this.tomSelect.close.bind(this.tomSelect) this.tomSelect.close = () => {} - - // Remove previous outside-click listener if still attached (prevents orphaned listeners) - if (this._onDocumentMousedown) { - document.removeEventListener('mousedown', this._onDocumentMousedown, true) - } - - // Listen for clicks outside the dropdown to cancel editing and close + if (this._onDocumentMousedown) document.removeEventListener('mousedown', this._onDocumentMousedown, true) this._onDocumentMousedown = (e) => { if (!this.tomSelect) return const dropdown = this.tomSelect.dropdown if (dropdown && !dropdown.contains(e.target)) { - // Cancel any active rename before closing const activeInput = dropdown.querySelector('.f-input-ordered-multiselect__option-edit-input') if (activeInput) this.cancelOptionRename(activeInput) this.restoreDropdownClose() } } - window.setTimeout(() => { - document.addEventListener('mousedown', this._onDocumentMousedown, true) - }, 0) + window.setTimeout(() => { document.addEventListener('mousedown', this._onDocumentMousedown, true) }, 0) } restoreDropdownClose (keepOpen) { - // Remove outside-click listener - if (this._onDocumentMousedown) { - document.removeEventListener('mousedown', this._onDocumentMousedown, true) - this._onDocumentMousedown = null - } - - // Remove any pending click blocker (e.g. rename started via mousedown but confirmed via Enter) - if (this._pendingClickBlocker && this.tomSelect) { - this.tomSelect.dropdown.removeEventListener('click', this._pendingClickBlocker, true) - this._pendingClickBlocker = null - } - + if (this._onDocumentMousedown) { document.removeEventListener('mousedown', this._onDocumentMousedown, true); this._onDocumentMousedown = null } + if (this._pendingClickBlocker && this.tomSelect) { this.tomSelect.dropdown.removeEventListener('click', this._pendingClickBlocker, true); this._pendingClickBlocker = null } if (!this.tomSelect || !this._originalClose) return this.tomSelect.close = this._originalClose this._originalClose = null - - // Always clear value silently to reset Tom Select's internal state this.tomSelect.clear(true) - if (keepOpen) return - this._needsReload = true this.tomSelect.close() this.tomSelect.blur() @@ -797,47 +514,26 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind async onOptionDeleteClick (e) { e.preventDefault() e.stopPropagation() - - const btn = e.currentTarget - const value = btn.dataset.value - - // Keep dropdown open during confirm dialogs (they steal focus → Tom Select would close) + const value = e.currentTarget.dataset.value + const optionEl = e.currentTarget.closest('.option') this.preventDropdownClose() - const success = await this.deleteItem(value) - if (success) { - const t = window.Folio.Input.OrderedMultiselect.t - window.FolioConsole.Ui.Flash.success(t('deleted')) - - // Animate option removal from dropdown - const optionEl = btn.closest('.option') + window.FolioConsole.Ui.Flash.success(window.Folio.Input.OrderedMultiselect.t('deleted')) if (optionEl) { optionEl.style.transition = 'opacity 0.3s, max-height 0.3s' optionEl.style.overflow = 'hidden' optionEl.style.maxHeight = optionEl.offsetHeight + 'px' optionEl.style.opacity = '0.5' - - window.setTimeout(() => { - optionEl.style.opacity = '0' - optionEl.style.maxHeight = '0' - optionEl.style.padding = '0' - optionEl.style.margin = '0' - }, 100) - - window.setTimeout(() => { - optionEl.remove() - }, 400) + window.setTimeout(() => { optionEl.style.opacity = '0'; optionEl.style.maxHeight = '0'; optionEl.style.padding = '0'; optionEl.style.margin = '0' }, 100) + window.setTimeout(() => { optionEl.remove() }, 400) } - // Check if any real options remain after this deletion const allOptions = this.tomSelect.dropdown.querySelectorAll('.option') const realRemaining = Array.from(allOptions).filter((el) => el !== optionEl) if (realRemaining.length === 0) { - // No options left — close dropdown completely this.restoreDropdownClose() } else { - // More options remain — keep dropdown open for further browsing/deleting this.restoreDropdownClose(true) } } else { @@ -851,258 +547,132 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind if (this._skipRender) return this.renderList() this.syncHiddenInputs() - this.dispatchChangeEvent() + this.element.dispatchEvent(new Event('change', { bubbles: true })) } renderList () { if (!this.hasListTarget) return - const escape = window.Folio.Input.OrderedMultiselect.escapeHtml const t = window.Folio.Input.OrderedMultiselect.t - const dragIcon = window.Folio.Input.OrderedMultiselect.iconHtml('drag', { height: 24 }) - const sortableHandle = this.sortableValue - ? `${dragIcon}` - : '' - + const dragIcon = this.sortableValue ? `${window.Folio.Input.OrderedMultiselect.iconHtml('drag', { height: 24 })}` : '' const editIcon = window.Folio.Input.OrderedMultiselect.iconHtml('edit_box', { height: 16 }) const closeIcon = window.Folio.Input.OrderedMultiselect.iconHtml('close', { height: 16 }) - - const createableActions = this.createableValue - ? (item) => ` - ` - : () => '' - const usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml - this.listTarget.innerHTML = this.itemsValue.map((item) => `
- ${sortableHandle} - - ${escape(item.label)} - ${usageHint(item.usage_labels, 'f-input-ordered-multiselect__item-usage')} - + ${dragIcon} + ${escape(item.label)}${usageHint(item.usage_labels, 'f-input-ordered-multiselect__item-usage')} - ${createableActions(item)} - + ${this.createableValue ? `` : ''} + -
- `).join('') - - // Reinitialize sortable after re-render - if (this.sortableValue && this._sortableInitialized) { - this.refreshSortable() - } + `).join('') + if (this.sortableValue && this._sortableInitialized) this.refreshSortable() } syncHiddenInputs () { if (!this.hasHiddenContainerTarget) return - - const paramBase = this.paramBaseValue - const foreignKey = this.foreignKeyValue + const p = this.paramBaseValue + const fk = this.foreignKeyValue let html = '' - let index = 0 - - // Selected items + let idx = 0 this.itemsValue.forEach((item) => { - if (item.id) { - html += `` - } - html += `` - html += `` - index++ + if (item.id) html += `` + html += `` + html += `` + idx++ }) - - // Removed items (mark for destruction) this.removedItemsValue.forEach((item) => { if (item.id) { - html += `` - html += `` - index++ + html += `` + idx++ } }) - this.hiddenContainerTarget.innerHTML = html } - dispatchChangeEvent () { - this.element.dispatchEvent(new Event('change', { bubbles: true })) - } - - // --- Item list actions --- + // --- Item list actions (remove, rename) --- onItemRemoveClick (e) { e.preventDefault() const value = e.currentTarget.dataset.value const item = this.itemsValue.find((i) => String(i.value) === String(value)) - if (!item) return - this.itemsValue = this.itemsValue.filter((i) => String(i.value) !== String(value)) - - // Track for _destroy if it was a persisted record - if (item.id) { - const updatedLabels = this._removeCurrentLabel(item.usage_labels) - this.removedItemsValue = [...this.removedItemsValue, { ...item, usage_labels: updatedLabels }] - } - + if (item.id) this.removedItemsValue = [...this.removedItemsValue, { ...item, usage_labels: this._removeCurrentLabel(item.usage_labels) }] // Update loadedOptions so dropdown shows updated usage_labels const valStr = String(value) if (this.loadedOptions[valStr]) { this.loadedOptions[valStr].usage_labels = this._removeCurrentLabel(this.loadedOptions[valStr].usage_labels) } - - // Mark dropdown for reload so the removed item appears again this._needsReload = true } onItemRenameClick (e) { e.preventDefault() - const btn = e.currentTarget const value = btn.dataset.value const label = btn.dataset.label const itemEl = btn.closest('.f-input-ordered-multiselect__item') - if (!itemEl) return - const escape = window.Folio.Input.OrderedMultiselect.escapeHtml - const t = window.Folio.Input.OrderedMultiselect.t const labelEl = itemEl.querySelector('.f-input-ordered-multiselect__item-label') const actionsEl = itemEl.querySelector('.f-input-ordered-multiselect__item-actions') const confirmIcon = window.Folio.Input.OrderedMultiselect.iconHtml('checkbox_marked', { height: 16 }) const item = this.itemsValue.find((i) => String(i.value) === String(value)) const usageHintStr = window.Folio.Input.OrderedMultiselect.usageHintHtml(item && item.usage_labels, 'f-input-ordered-multiselect__item-usage') - - labelEl.innerHTML = ` -
- -
- ${usageHintStr} - ` - - // Replace action buttons with green confirm button - actionsEl.innerHTML = ` - - ${confirmIcon} - - ` - + labelEl.innerHTML = `
+ +
${usageHintStr}` + actionsEl.innerHTML = `${confirmIcon}` const input = labelEl.querySelector('input') const confirmBtn = actionsEl.querySelector('.f-input-ordered-multiselect__item-confirm') - - // Manual event listeners input.addEventListener('input', (e) => this.onItemRenameInputCheck(e)) input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault() - e.stopPropagation() - this.onItemRenameSubmit(e) - } else if (e.key === 'Escape') { - this.onItemRenameCancel(e) - } + if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); this.onItemRenameSubmit(e) } + else if (e.key === 'Escape') this.onItemRenameCancel(e) }) input.addEventListener('blur', (e) => { - // Don't cancel if clicking the confirm button if (e.relatedTarget && e.relatedTarget.closest('.f-input-ordered-multiselect__item-confirm')) return this.onItemRenameCancel(e) }) - - confirmBtn.addEventListener('mousedown', (e) => { - e.preventDefault() // prevent blur on input - }) + confirmBtn.addEventListener('mousedown', (e) => e.preventDefault()) confirmBtn.addEventListener('click', (e) => { e.preventDefault() this.onItemRenameSubmit({ target: input, preventDefault: () => {}, stopPropagation: () => {} }) }) - input.focus() input.select() } onItemRenameInputCheck (e) { - const input = e.target || e.currentTarget - const originalLabel = input.dataset.originalLabel - const existingLabels = this.itemsValue.map((i) => i.label) - const t = window.Folio.Input.OrderedMultiselect.t - - const isDuplicate = window.Folio.Input.OrderedMultiselect.isDuplicateLabel( - input.value, originalLabel, existingLabels, this.loadedOptions - ) - - input.classList.toggle('is-invalid', isDuplicate) - - // Swap usage hint with error message - const itemLabel = input.closest('.f-input-ordered-multiselect__item-label') - if (!itemLabel) return - const hintEl = itemLabel.querySelector('.f-input-ordered-multiselect__item-usage') - if (hintEl) { - if (isDuplicate) { - if (!hintEl.dataset.originalText) hintEl.dataset.originalText = hintEl.textContent - hintEl.textContent = t('alreadyExists') - hintEl.classList.add('text-danger') - } else { - if (hintEl.dataset.originalText) hintEl.textContent = hintEl.dataset.originalText - hintEl.classList.remove('text-danger') - } - } + this.checkRenameDuplicate(e.target || e.currentTarget, '.f-input-ordered-multiselect__item-label', '.f-input-ordered-multiselect__item-usage') } async onItemRenameSubmit (e) { e.preventDefault() e.stopPropagation() - const input = e.target.closest('.f-input-ordered-multiselect__item-rename-input') || e.currentTarget const value = input.dataset.value const newLabel = input.value.trim() - const originalLabel = input.dataset.originalLabel - - if (!newLabel || newLabel === originalLabel) { - this.renderList() - return - } - - // Check for duplicates at submit time - const existingLabels = this.itemsValue.map((i) => i.label) - if (window.Folio.Input.OrderedMultiselect.isDuplicateLabel(newLabel, originalLabel, existingLabels, this.loadedOptions)) { + if (!newLabel || newLabel === input.dataset.originalLabel) { this.renderList(); return } + if (window.Folio.Input.OrderedMultiselect.isDuplicateLabel(newLabel, input.dataset.originalLabel, this.itemsValue.map((i) => i.label), this.loadedOptions)) { input.classList.add('is-invalid') return } - - const itemEl = input.closest('.f-input-ordered-multiselect__item') const success = await this.renameItem(value, newLabel, true) + this.renderList() if (success) { - // Re-render then flash the renamed item - this.renderList() - if (itemEl) { - const newItemEl = this.listTarget.querySelector(`[data-value="${value}"]`) - if (newItemEl) { - newItemEl.classList.add('f-input-ordered-multiselect__item--flash') - window.setTimeout(() => newItemEl.classList.remove('f-input-ordered-multiselect__item--flash'), 600) - } + const newItemEl = this.listTarget.querySelector(`[data-value="${value}"]`) + if (newItemEl) { + newItemEl.classList.add('f-input-ordered-multiselect__item--flash') + window.setTimeout(() => newItemEl.classList.remove('f-input-ordered-multiselect__item--flash'), 600) } - } else { - this.renderList() } } onItemRenameCancel (e) { - if (e.type === 'blur' && e.relatedTarget && e.relatedTarget.closest('.f-input-ordered-multiselect__item')) { - return - } + if (e.type === 'blur' && e.relatedTarget && e.relatedTarget.closest('.f-input-ordered-multiselect__item')) return this.renderList() } @@ -1111,13 +681,11 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind initSortable () { window.Folio.RemoteScripts.run('html5sortable', () => { if (!this.hasListTarget) return - window.sortable(this.listTarget, { items: '.f-input-ordered-multiselect__item', handle: '.f-input-ordered-multiselect__item-handle', placeholder: '
' }) - this._onSortUpdate = () => this.onSortUpdate() this.listTarget.addEventListener('sortupdate', this._onSortUpdate) this._sortableInitialized = true @@ -1125,9 +693,7 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind } refreshSortable () { - if (window.sortable && this.hasListTarget) { - window.sortable(this.listTarget) - } + if (window.sortable && this.hasListTarget) window.sortable(this.listTarget) } destroySortable () { @@ -1139,37 +705,43 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind } onSortUpdate () { - const itemEls = this.listTarget.querySelectorAll('.f-input-ordered-multiselect__item') - const newOrder = Array.from(itemEls).map((el) => el.dataset.value) - - const reordered = newOrder.map((val) => - this.itemsValue.find((item) => String(item.value) === String(val)) - ).filter(Boolean) - - // Update without triggering re-render (DOM is already in correct order) + const reordered = Array.from(this.listTarget.querySelectorAll('.f-input-ordered-multiselect__item')) + .map((el) => this.itemsValue.find((item) => String(item.value) === String(el.dataset.value))) + .filter(Boolean) this._skipRender = true - try { - this.itemsValue = reordered - } finally { - this._skipRender = false - } + try { this.itemsValue = reordered } finally { this._skipRender = false } this.syncHiddenInputs() - this.dispatchChangeEvent() + this.element.dispatchEvent(new Event('change', { bubbles: true })) } - // --- Usage labels helpers --- + // --- Shared helpers --- + + checkRenameDuplicate (input, containerSelector, hintSelector) { + const dup = window.Folio.Input.OrderedMultiselect.isDuplicateLabel(input.value, input.dataset.originalLabel, this.itemsValue.map((i) => i.label), this.loadedOptions) + input.classList.toggle('is-invalid', dup) + const container = input.closest(containerSelector) + const hintEl = container && container.querySelector(hintSelector) + if (!hintEl) return + if (dup) { + if (!hintEl.dataset.originalText) hintEl.dataset.originalText = hintEl.textContent + hintEl.textContent = window.Folio.Input.OrderedMultiselect.t('alreadyExists') + hintEl.classList.add('text-danger') + } else { + if (hintEl.dataset.originalText) hintEl.textContent = hintEl.dataset.originalText + hintEl.classList.remove('text-danger') + } + } _addCurrentLabel (labels) { - const current = this.currentRecordLabelValue - if (!current) return labels || [] + const c = this.currentRecordLabelValue + if (!c) return labels || [] const arr = labels ? [...labels] : [] - if (!arr.includes(current)) arr.push(current) + if (!arr.includes(c)) arr.push(c) return arr } _removeCurrentLabel (labels) { - const current = this.currentRecordLabelValue - if (!current || !labels) return labels || [] - return labels.filter((l) => l !== current) + const c = this.currentRecordLabelValue + return (!c || !labels) ? (labels || []) : labels.filter((l) => l !== c) } }) From 590ee7a8e6e54ec0bae50aa78789fd04738267d2 Mon Sep 17 00:00:00 2001 From: Viktor <38672169+vdedek@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:20:53 +0100 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20inline=20rename=20UX=20=E2=80=94=20d?= =?UTF-8?q?ouble=20rename,=20cursor=20positioning,=20blur=20race=20conditi?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dropdown: cancel active rename before starting a new one (restoreOptionHtml) - Dropdown: stopImmediatePropagation on edit input mousedown/click for cursor positioning - Selected list: mousedown preventDefault on action buttons to prevent focus stealing - Selected list: renderList() + double _blurCancelTimer clear to safely switch renames - Cleanup: _blurCancelTimer in disconnect(), simplify onItemRenameCancel --- .../folio/input/ordered_multiselect.js | 59 ++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/folio/input/ordered_multiselect.js b/app/assets/javascripts/folio/input/ordered_multiselect.js index 569826a00e..0ae23759f9 100644 --- a/app/assets/javascripts/folio/input/ordered_multiselect.js +++ b/app/assets/javascripts/folio/input/ordered_multiselect.js @@ -115,6 +115,7 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind } disconnect () { + if (this._blurCancelTimer) { clearTimeout(this._blurCancelTimer); this._blurCancelTimer = null } if (this._onDocumentMousedown) { document.removeEventListener('mousedown', this._onDocumentMousedown, true); this._onDocumentMousedown = null } this.destroyTomSelect() this.destroySortable() @@ -235,6 +236,21 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind const noop = () => {} const fakeEvent = (target) => ({ currentTarget: target, preventDefault: noop, stopPropagation: noop }) this._onDropdownMousedown = (e) => { + // Let clicks inside the rename input through for cursor positioning, + // but stop Tom Select from treating it as option selection. + // Block both mousedown propagation AND the subsequent click event — + // Tom Select selects options on click, not mousedown. + if (e.target.closest('.f-input-ordered-multiselect__option-edit-input')) { + // stopImmediatePropagation prevents Tom Select's own capture-phase handlers + // from calling preventDefault (which would block cursor positioning). + // We do NOT call preventDefault ourselves — the browser must handle cursor placement. + e.stopImmediatePropagation() + // Block the subsequent click so Tom Select doesn't select the option + if (this._pendingClickBlocker) dropdown.removeEventListener('click', this._pendingClickBlocker, true) + this._pendingClickBlocker = (ce) => { ce.stopImmediatePropagation(); this._pendingClickBlocker = null } + dropdown.addEventListener('click', this._pendingClickBlocker, { capture: true, once: true }) + return + } const submitBtn = e.target.closest('.f-input-ordered-multiselect__option-confirm') const deleteBtn = e.target.closest('.f-input-ordered-multiselect__option-action--danger') const renameBtn = e.target.closest('.f-input-ordered-multiselect__option-action:not(.f-input-ordered-multiselect__option-action--danger):not(.f-input-ordered-multiselect__option-confirm)') @@ -401,6 +417,15 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind onOptionRenameClick (e) { e.preventDefault() e.stopPropagation() + // Cancel any active rename in the dropdown before starting a new one — + // just restore HTML, don't touch close/focus (we're staying in edit mode) + if (this.tomSelect) { + const activeInput = this.tomSelect.dropdown.querySelector('.f-input-ordered-multiselect__option-edit-input') + if (activeInput) { + const activeEl = activeInput.closest('.f-input-ordered-multiselect__option-with-actions') + if (activeEl) this.restoreOptionHtml(activeEl, activeInput.dataset.value, activeInput.dataset.originalLabel) + } + } const btn = e.currentTarget const value = btn.dataset.value const label = btn.dataset.label @@ -514,6 +539,14 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind async onOptionDeleteClick (e) { e.preventDefault() e.stopPropagation() + // Cancel any active rename in the dropdown before deleting + if (this.tomSelect) { + const activeInput = this.tomSelect.dropdown.querySelector('.f-input-ordered-multiselect__option-edit-input') + if (activeInput) { + const activeEl = activeInput.closest('.f-input-ordered-multiselect__option-with-actions') + if (activeEl) this.restoreOptionHtml(activeEl, activeInput.dataset.value, activeInput.dataset.originalLabel) + } + } const value = e.currentTarget.dataset.value const optionEl = e.currentTarget.closest('.option') this.preventDropdownClose() @@ -568,6 +601,12 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind `).join('') if (this.sortableValue && this._sortableInitialized) this.refreshSortable() + // Prevent mousedown on action buttons from stealing focus from an active rename input. + // Without this, blur fires before click, the 50ms timer destroys the DOM, + // and the click event never reaches the target button. + this.listTarget.querySelectorAll('.f-input-ordered-multiselect__item-action').forEach((btn) => { + btn.addEventListener('mousedown', (e) => e.preventDefault()) + }) } syncHiddenInputs () { @@ -610,10 +649,15 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind onItemRenameClick (e) { e.preventDefault() - const btn = e.currentTarget - const value = btn.dataset.value - const label = btn.dataset.label - const itemEl = btn.closest('.f-input-ordered-multiselect__item') + if (this._blurCancelTimer) { clearTimeout(this._blurCancelTimer); this._blurCancelTimer = null } + const value = e.currentTarget.dataset.value + const label = e.currentTarget.dataset.label + // Cancel any active rename by re-rendering the list, then find the target item fresh + this.renderList() + // renderList() destroys the previous rename input, triggering its blur handler + // which sets a new timer — clear it so it doesn't wipe out the rename we're about to set up + if (this._blurCancelTimer) { clearTimeout(this._blurCancelTimer); this._blurCancelTimer = null } + const itemEl = this.listTarget.querySelector(`.f-input-ordered-multiselect__item[data-value="${value}"]`) if (!itemEl) return const escape = window.Folio.Input.OrderedMultiselect.escapeHtml const labelEl = itemEl.querySelector('.f-input-ordered-multiselect__item-label') @@ -634,7 +678,9 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind }) input.addEventListener('blur', (e) => { if (e.relatedTarget && e.relatedTarget.closest('.f-input-ordered-multiselect__item-confirm')) return - this.onItemRenameCancel(e) + // Delay cancel so that a click on another rename button can fire first + // and cancel the timer via renderList() in onItemRenameClick + this._blurCancelTimer = window.setTimeout(() => { this._blurCancelTimer = null; this.renderList() }, 50) }) confirmBtn.addEventListener('mousedown', (e) => e.preventDefault()) confirmBtn.addEventListener('click', (e) => { @@ -671,8 +717,7 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind } } - onItemRenameCancel (e) { - if (e.type === 'blur' && e.relatedTarget && e.relatedTarget.closest('.f-input-ordered-multiselect__item')) return + onItemRenameCancel () { this.renderList() } From c6c764cfe4a21ac9bf620d943498ea1bc5dfb537 Mon Sep 17 00:00:00 2001 From: Viktor <38672169+vdedek@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:30:56 +0100 Subject: [PATCH 5/5] feat: add show_usage flag to stimulus_ordered_multiselect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows hiding "Použito v:" usage labels via show_usage: false. When disabled, items are vertically centered (--no-usage CSS modifier). Default: true (backwards compatible). --- .../javascripts/folio/input/ordered_multiselect.js | 9 +++++---- .../stylesheets/folio/input/_ordered_multiselect.sass | 6 ++++++ app/helpers/folio/console/react_helper.rb | 3 +++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/folio/input/ordered_multiselect.js b/app/assets/javascripts/folio/input/ordered_multiselect.js index 0ae23759f9..7c8bb8b006 100644 --- a/app/assets/javascripts/folio/input/ordered_multiselect.js +++ b/app/assets/javascripts/folio/input/ordered_multiselect.js @@ -99,6 +99,7 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind sortable: { type: Boolean, default: true }, currentRecordLabel: { type: String, default: '' }, createable: { type: Boolean, default: false }, + showUsage: { type: Boolean, default: true }, createUrl: String, updateUrl: String, deleteUrl: String @@ -295,7 +296,7 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind const t = window.Folio.Input.OrderedMultiselect.t const editIcon = window.Folio.Input.OrderedMultiselect.iconHtml('edit_box', { height: 16 }) const deleteIcon = window.Folio.Input.OrderedMultiselect.iconHtml('delete', { height: 16 }) - const usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml(usageLabels, 'f-input-ordered-multiselect__option-usage', true) + const usageHint = this.showUsageValue ? window.Folio.Input.OrderedMultiselect.usageHintHtml(usageLabels, 'f-input-ordered-multiselect__option-usage', true) : '' return `${escape(label)}${usageHint} @@ -435,7 +436,7 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind const escape = window.Folio.Input.OrderedMultiselect.escapeHtml const confirmIcon = window.Folio.Input.OrderedMultiselect.iconHtml('checkbox_marked', { height: 16 }) const optionData = this.tomSelect ? this.tomSelect.options[value] : null - const usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml(optionData ? optionData.usage_labels : [], 'f-input-ordered-multiselect__option-usage', true) + const usageHint = this.showUsageValue ? window.Folio.Input.OrderedMultiselect.usageHintHtml(optionData ? optionData.usage_labels : [], 'f-input-ordered-multiselect__option-usage', true) : '' optionEl.innerHTML = ` ${usageHint} @@ -594,7 +595,7 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind this.listTarget.innerHTML = this.itemsValue.map((item) => `
${dragIcon} - ${escape(item.label)}${usageHint(item.usage_labels, 'f-input-ordered-multiselect__item-usage')} + ${escape(item.label)}${this.showUsageValue ? usageHint(item.usage_labels, 'f-input-ordered-multiselect__item-usage') : ''} ${this.createableValue ? `` : ''} @@ -664,7 +665,7 @@ window.Folio.Stimulus.register('f-input-ordered-multiselect', class extends wind const actionsEl = itemEl.querySelector('.f-input-ordered-multiselect__item-actions') const confirmIcon = window.Folio.Input.OrderedMultiselect.iconHtml('checkbox_marked', { height: 16 }) const item = this.itemsValue.find((i) => String(i.value) === String(value)) - const usageHintStr = window.Folio.Input.OrderedMultiselect.usageHintHtml(item && item.usage_labels, 'f-input-ordered-multiselect__item-usage') + const usageHintStr = this.showUsageValue ? window.Folio.Input.OrderedMultiselect.usageHintHtml(item && item.usage_labels, 'f-input-ordered-multiselect__item-usage') : '' labelEl.innerHTML = `
${usageHintStr}` diff --git a/app/assets/stylesheets/folio/input/_ordered_multiselect.sass b/app/assets/stylesheets/folio/input/_ordered_multiselect.sass index e6ba8adb4b..eee0f6fd93 100644 --- a/app/assets/stylesheets/folio/input/_ordered_multiselect.sass +++ b/app/assets/stylesheets/folio/input/_ordered_multiselect.sass @@ -142,6 +142,12 @@ flex: 1 min-width: 0 +// No usage labels variant — center-align items vertically +.f-input-ordered-multiselect--no-usage + .f-input-ordered-multiselect__item, + .f-input-ordered-multiselect__option-with-actions + align-items: center + // Not sortable variant .f-input-ordered-multiselect--not-sortable .f-input-ordered-multiselect__item diff --git a/app/helpers/folio/console/react_helper.rb b/app/helpers/folio/console/react_helper.rb index 22bc2c865f..e6e0e7f365 100644 --- a/app/helpers/folio/console/react_helper.rb +++ b/app/helpers/folio/console/react_helper.rb @@ -229,12 +229,14 @@ def stimulus_ordered_multiselect(f, order_scope: :ordered, sortable: true, createable: false, + show_usage: true, required: nil, create_url: nil, update_url: nil, delete_url: nil) class_name = "f-input-ordered-multiselect" class_name += " f-input-ordered-multiselect--not-sortable" unless sortable + class_name += " f-input-ordered-multiselect--no-usage" unless show_usage klass = f.object.class reflection = klass.reflections[relation_name.to_s] @@ -291,6 +293,7 @@ def stimulus_ordered_multiselect(f, "f-input-ordered-multiselect-foreign-key-value" => reflection.foreign_key, "f-input-ordered-multiselect-sortable-value" => sortable ? "true" : "false", "f-input-ordered-multiselect-createable-value" => createable ? "true" : "false", + "f-input-ordered-multiselect-show-usage-value" => show_usage ? "true" : "false", "f-input-ordered-multiselect-current-record-label-value" => current_record_label, }