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..7c8bb8b006
--- /dev/null
+++ b/app/assets/javascripts/folio/input/ordered_multiselect.js
@@ -0,0 +1,793 @@
+//= 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 },
+ showUsage: { type: Boolean, default: true },
+ 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._blurCancelTimer) { clearTimeout(this._blurCancelTimer); this._blurCancelTimer = null }
+ if (this._onDocumentMousedown) { document.removeEventListener('mousedown', this._onDocumentMousedown, true); this._onDocumentMousedown = null }
+ this.destroyTomSelect()
+ this.destroySortable()
+ }
+
+ // --- Tom Select setup ---
+
+ 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 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 selectedIds = self.itemsValue.map((i) => String(i.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))
+
+ // 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())
+ },
+ render: {
+ option (data, escape) {
+ 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)}…
`
+ },
+ no_results (data) {
+ if (data.input && self.itemsValue.some((i) => i.label.toLowerCase().trim() === data.input.toLowerCase().trim())) {
+ return `${t('alreadyOnList')}
`
+ }
+ return `${t('noResults')}
`
+ },
+ loading () { return `` }
+ },
+ onChange (value) {
+ if (!value || value === '__no_results__') return
+ self.onItemSelected(value)
+ window.setTimeout(() => { if (self.tomSelect) { self.tomSelect.clear(true); self._needsReload = true } }, 0)
+ }
+ }
+ if (this.createableValue) {
+ 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('') }
+ 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
+ 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 }
+ }
+
+ bindDropdownEvents () {
+ if (!this.tomSelect) return
+ const dropdown = this.tomSelect.dropdown
+ 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)')
+ if (submitBtn || deleteBtn || renameBtn) {
+ e.preventDefault()
+ e.stopPropagation()
+ 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(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(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) 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 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)
+ }
+
+ // --- 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 = this.showUsageValue ? 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)}
`
+ }
+
+ 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)
+ if (valueStr.startsWith('__create__')) return
+ if (this.itemsValue.find((i) => String(i.value) === valueStr)) return
+ const removed = this.removedItemsValue.find((i) => String(i.value) === valueStr)
+ if (removed) {
+ this.removedItemsValue = this.removedItemsValue.filter((i) => String(i.value) !== valueStr)
+ this.itemsValue = [...this.itemsValue, { ...removed, usage_labels: this._addCurrentLabel(removed.usage_labels) }]
+ return
+ }
+ const option = this.tomSelect.options[value]
+ if (!option) return
+ this.itemsValue = [...this.itemsValue, {
+ id: null, label: option.text, value: parseInt(valueStr, 10) || valueStr,
+ usage_labels: this._addCurrentLabel(option.usage_labels || [])
+ }]
+ }
+
+ async createItem (label) {
+ if (this._busy) return
+ const t = window.Folio.Input.OrderedMultiselect.t
+ 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 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: this._addCurrentLabel([])
+ }]
+ 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))
+ 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
+ try {
+ 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) {
+ 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
+ this._busy = true
+ try {
+ 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) {
+ 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
+ await window.Folio.Api.apiDelete(this.deleteUrlValue, { id, confirmed: 'true' })
+ this.itemsValue = this.itemsValue.filter((i) => String(i.value) !== String(id))
+ 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 inline rename & delete ---
+
+ 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
+ const optionEl = btn.closest('.f-input-ordered-multiselect__option-with-actions')
+ if (!optionEl) return
+ this.preventDropdownClose()
+ 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 = this.showUsageValue ? window.Folio.Input.OrderedMultiselect.usageHintHtml(optionData ? optionData.usage_labels : [], 'f-input-ordered-multiselect__option-usage', true) : ''
+ optionEl.innerHTML = `
+
+ ${usageHint}
+
+
+ ${confirmIcon}
+ `
+ const input = optionEl.querySelector('input')
+ input.focus()
+ input.select()
+ }
+
+ onOptionRenameInput (e) {
+ 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()
+ if (!newLabel || newLabel === input.dataset.originalLabel) { this.cancelOptionRename(input); return }
+ if (input.classList.contains('is-invalid')) return
+ const success = await this.renameItem(value, newLabel, this.itemsValue.some((i) => String(i.value) === String(value)), true)
+ if (success) {
+ 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)
+ 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 optionEl = input.closest('.f-input-ordered-multiselect__option-with-actions')
+ if (optionEl) this.restoreOptionHtml(optionEl, input.dataset.value, input.dataset.originalLabel)
+ this.restoreDropdownClose(true)
+ this.refocusTomSelect()
+ }
+
+ refocusTomSelect () {
+ if (!this.tomSelect) return
+ this.tomSelect.isFocused = true
+ this.tomSelect.refreshState()
+ this.tomSelect.ignoreFocus = true
+ this.tomSelect.control_input.focus()
+ window.setTimeout(() => { if (this.tomSelect) this.tomSelect.ignoreFocus = false }, 0)
+ }
+
+ resetTomSelect () {
+ if (!this.tomSelect) return
+ this.tomSelect.clear(true)
+ 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 = () => {}
+ 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)) {
+ 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) {
+ 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
+ this.tomSelect.clear(true)
+ if (keepOpen) return
+ this._needsReload = true
+ this.tomSelect.close()
+ this.tomSelect.blur()
+ }
+
+ 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()
+ const success = await this.deleteItem(value)
+ if (success) {
+ 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)
+ }
+ // 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) {
+ this.restoreDropdownClose()
+ } else {
+ this.restoreDropdownClose(true)
+ }
+ } else {
+ this.restoreDropdownClose()
+ }
+ }
+
+ // --- Selected items list ---
+
+ itemsValueChanged () {
+ if (this._skipRender) return
+ this.renderList()
+ this.syncHiddenInputs()
+ 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 = 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 usageHint = window.Folio.Input.OrderedMultiselect.usageHintHtml
+ this.listTarget.innerHTML = this.itemsValue.map((item) => `
+
+ ${dragIcon}
+ ${escape(item.label)}${this.showUsageValue ? usageHint(item.usage_labels, 'f-input-ordered-multiselect__item-usage') : ''}
+
+ ${this.createableValue ? `` : ''}
+
+
+
`).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 () {
+ if (!this.hasHiddenContainerTarget) return
+ const p = this.paramBaseValue
+ const fk = this.foreignKeyValue
+ let html = ''
+ let idx = 0
+ this.itemsValue.forEach((item) => {
+ if (item.id) html += ``
+ html += ``
+ html += ``
+ idx++
+ })
+ this.removedItemsValue.forEach((item) => {
+ if (item.id) {
+ html += ``
+ idx++
+ }
+ })
+ this.hiddenContainerTarget.innerHTML = html
+ }
+
+ // --- 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))
+ 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)
+ }
+ this._needsReload = true
+ }
+
+ onItemRenameClick (e) {
+ e.preventDefault()
+ 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')
+ 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 = this.showUsageValue ? window.Folio.Input.OrderedMultiselect.usageHintHtml(item && item.usage_labels, 'f-input-ordered-multiselect__item-usage') : ''
+ labelEl.innerHTML = `
+
+
${usageHintStr}`
+ actionsEl.innerHTML = `${confirmIcon}`
+ const input = labelEl.querySelector('input')
+ const confirmBtn = actionsEl.querySelector('.f-input-ordered-multiselect__item-confirm')
+ 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) => {
+ if (e.relatedTarget && e.relatedTarget.closest('.f-input-ordered-multiselect__item-confirm')) return
+ // 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) => {
+ e.preventDefault()
+ this.onItemRenameSubmit({ target: input, preventDefault: () => {}, stopPropagation: () => {} })
+ })
+ input.focus()
+ input.select()
+ }
+
+ onItemRenameInputCheck (e) {
+ 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()
+ 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 success = await this.renameItem(value, newLabel, true)
+ this.renderList()
+ if (success) {
+ 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)
+ }
+ }
+ }
+
+ onItemRenameCancel () {
+ 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 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 }
+ this.syncHiddenInputs()
+ this.element.dispatchEvent(new Event('change', { bubbles: true }))
+ }
+
+ // --- 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 c = this.currentRecordLabelValue
+ if (!c) return labels || []
+ const arr = labels ? [...labels] : []
+ if (!arr.includes(c)) arr.push(c)
+ return arr
+ }
+
+ _removeCurrentLabel (labels) {
+ const c = this.currentRecordLabelValue
+ return (!c || !labels) ? (labels || []) : labels.filter((l) => l !== c)
+ }
+})
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..eee0f6fd93
--- /dev/null
+++ b/app/assets/stylesheets/folio/input/_ordered_multiselect.sass
@@ -0,0 +1,158 @@
+.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
+
+// No usage labels variant — center-align items vertically
+.f-input-ordered-multiselect--no-usage
+ .f-input-ordered-multiselect__item,
+ .f-input-ordered-multiselect__option-with-actions
+ align-items: center
+
+// Not sortable variant
+.f-input-ordered-multiselect--not-sortable
+ .f-input-ordered-multiselect__item
+ 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..e6e0e7f365 100644
--- a/app/helpers/folio/console/react_helper.rb
+++ b/app/helpers/folio/console/react_helper.rb
@@ -223,6 +223,135 @@ def react_ordered_multiselect(f,
end
end
+ def stimulus_ordered_multiselect(f,
+ relation_name,
+ scope: nil,
+ order_scope: :ordered,
+ sortable: true,
+ createable: false,
+ show_usage: true,
+ required: nil,
+ create_url: nil,
+ update_url: nil,
+ delete_url: nil)
+ class_name = "f-input-ordered-multiselect"
+ class_name += " f-input-ordered-multiselect--not-sortable" unless sortable
+ class_name += " f-input-ordered-multiselect--no-usage" unless show_usage
+
+ klass = f.object.class
+ reflection = klass.reflections[relation_name.to_s]
+ 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-show-usage-value" => show_usage ? "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],