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) => `