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],