diff --git a/app/controllers/admin/streams_controller.rb b/app/controllers/admin/streams_controller.rb index 83b1c64..d967416 100644 --- a/app/controllers/admin/streams_controller.rb +++ b/app/controllers/admin/streams_controller.rb @@ -33,10 +33,10 @@ def index persist_sort_preferences if persist_sort_preferences? streams_scope = Stream.filtered(filter_params) - streams_scope = streams_scope.includes(:streamer) if streams_scope.where.not(streamer_id: nil).exists? streams_scope = streams_scope.sorted(@sort_column, @sort_direction) @pagy, @streams = pagy(streams_scope, items: 20) + @streams = @streams.preload(:streamer) if @streams.where.not(streamer_id: nil).exists? respond_to do |format| format.html diff --git a/app/helpers/admin/streams_helper.rb b/app/helpers/admin/streams_helper.rb index 593e9a0..b465a58 100644 --- a/app/helpers/admin/streams_helper.rb +++ b/app/helpers/admin/streams_helper.rb @@ -38,6 +38,14 @@ def streamer_options end end + def streamer_label_for(streamer_id) + return nil if streamer_id.blank? + + streamer_options.each_with_object({}) do |(label, id), memo| + memo[id] = label + end[streamer_id] + end + private def stream_sort_params(column, direction) diff --git a/app/javascript/controllers/collaborative_spreadsheet/cell_renderer.js b/app/javascript/controllers/collaborative_spreadsheet/cell_renderer.js index 843f7f7..5073db8 100644 --- a/app/javascript/controllers/collaborative_spreadsheet/cell_renderer.js +++ b/app/javascript/controllers/collaborative_spreadsheet/cell_renderer.js @@ -156,6 +156,7 @@ export class CellRenderer { const nameElement = document.getElementById(`stream_${streamId}_streamer_name`) const platformElement = document.getElementById(`stream_${streamId}_streamer_platform`) const selectElement = document.getElementById(`stream_${streamId}_streamer_select`) + const hiddenElement = document.getElementById(`stream_${streamId}_streamer_id`) if (!nameElement || !platformElement) { console.warn(`Streamer elements not found for stream ${streamId}`) @@ -169,7 +170,28 @@ export class CellRenderer { platformElement.textContent = platformText if (selectElement) { - selectElement.value = streamerId || '' + let label = streamerName || '' + if (selectElement.closest('[data-controller~="streamer-autocomplete"]') && streamerId) { + const container = selectElement.closest('[data-controller~="streamer-autocomplete"]') + const optionsJson = container?.dataset?.streamerAutocompleteOptionsValue + if (optionsJson) { + try { + const options = JSON.parse(optionsJson) + const match = options.find(([, id]) => String(id) === String(streamerId)) + if (match) label = match[0] + } catch (error) { + console.warn('Failed to parse streamer options JSON', error) + } + } + } else if (streamerName && streamerPlatform) { + label = `${streamerName} (${streamerPlatform})` + } + + selectElement.value = streamerId ? label : '' + } + + if (hiddenElement) { + hiddenElement.value = streamerId || '' } const elements = [nameElement, platformElement] diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index eab401e..6ca8592 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -5,6 +5,8 @@ import ModalController from './modal_controller' import SearchController from './search_controller' import MobileMenuController from './mobile_menu_controller' import CollaborativeSpreadsheetController from './collaborative_spreadsheet_controller' +import ReassignDropdownController from './reassign_dropdown_controller' +import StreamerAutocompleteController from './streamer_autocomplete_controller' import StreamTablePreferencesController from './stream_table_preferences_controller' import StreamViewController from './stream_view_controller' import ToastController from './toast_controller' @@ -14,6 +16,8 @@ application.register('modal', ModalController) application.register('search', SearchController) application.register('mobile-menu', MobileMenuController) application.register('collaborative-spreadsheet', CollaborativeSpreadsheetController) +application.register('reassign-dropdown', ReassignDropdownController) +application.register('streamer-autocomplete', StreamerAutocompleteController) application.register('stream-table-preferences', StreamTablePreferencesController) application.register('stream-view', StreamViewController) application.register('toast', ToastController) diff --git a/app/javascript/controllers/reassign_dropdown_controller.js b/app/javascript/controllers/reassign_dropdown_controller.js new file mode 100644 index 0000000..0cc2af6 --- /dev/null +++ b/app/javascript/controllers/reassign_dropdown_controller.js @@ -0,0 +1,96 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + static targets = ['panel', 'trigger'] + + connect () { + this.handleDocumentClick = this.handleDocumentClick.bind(this) + this.handleDocumentKeydown = this.handleDocumentKeydown.bind(this) + } + + disconnect () { + this.removeDocumentListeners() + } + + toggle (event) { + if (event) event.preventDefault() + if (event) event.stopPropagation() + + if (this.isOpen()) { + this.close() + } else { + this.open() + } + } + + open () { + if (this.isOpen()) return + + this.panelTarget.hidden = false + this.triggerTarget.setAttribute('aria-expanded', 'true') + document.addEventListener('pointerdown', this.handleDocumentClick) + document.addEventListener('keydown', this.handleDocumentKeydown) + this.focusFirstInput() + } + + close () { + if (!this.isOpen()) return + + this.panelTarget.hidden = true + this.triggerTarget.setAttribute('aria-expanded', 'false') + this.removeDocumentListeners() + } + + handleDocumentClick (event) { + const path = event.composedPath ? event.composedPath() : [] + if (path.includes(this.element) || this.element.contains(event.target)) return + + this.close() + } + + handleDocumentKeydown (event) { + if (event.key === 'Escape') { + this.close() + } + } + + removeDocumentListeners () { + document.removeEventListener('pointerdown', this.handleDocumentClick) + document.removeEventListener('keydown', this.handleDocumentKeydown) + } + + keepOpen (event) { + if (event) event.stopPropagation() + } + + focusFirstInput () { + const input = this.findFocusableInput() + if (input) { + requestAnimationFrame(() => { + input.focus({ preventScroll: true }) + if (input.select) input.select() + }) + setTimeout(() => { + input.focus({ preventScroll: true }) + }, 0) + } + } + + findFocusableInput () { + const candidates = Array.from( + this.panelTarget.querySelectorAll('input, select, textarea, button') + ) + + return candidates.find((element) => { + if (element.disabled) return false + if (element.tagName.toLowerCase() === 'input') { + return element.type !== 'hidden' + } + return true + }) + } + + isOpen () { + return !this.panelTarget.hidden + } +} diff --git a/app/javascript/controllers/stream_table_preferences_controller.js b/app/javascript/controllers/stream_table_preferences_controller.js index 5f6596f..cb40b4c 100644 --- a/app/javascript/controllers/stream_table_preferences_controller.js +++ b/app/javascript/controllers/stream_table_preferences_controller.js @@ -76,6 +76,10 @@ export default class extends Controller { return true } + if (this.element.querySelector('[data-controller~="reassign-dropdown"] [data-reassign-dropdown-target="panel"]:not([hidden])')) { + return true + } + const currentUserId = this.element.getAttribute('data-collaborative-spreadsheet-current-user-id-value') if (currentUserId) { const lockedByCurrentUser = this.element.querySelector( diff --git a/app/javascript/controllers/streamer_autocomplete_controller.js b/app/javascript/controllers/streamer_autocomplete_controller.js new file mode 100644 index 0000000..980ecc1 --- /dev/null +++ b/app/javascript/controllers/streamer_autocomplete_controller.js @@ -0,0 +1,108 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + static targets = ['input', 'list', 'hidden'] + static values = { + options: Array, + maxResults: { type: Number, default: 30 } + } + + connect () { + this.allOptions = this.normalizeOptions(this.optionsValue || []) + this.closeList() + } + + show () { + this.filter() + } + + filter () { + const query = this.inputTarget.value.trim().toLowerCase() + const matches = query.length === 0 + ? this.allOptions + : this.allOptions.filter(option => option.label.toLowerCase().includes(query)) + + this.renderList(matches.slice(0, this.maxResultsValue)) + this.openList() + } + + scheduleClose () { + clearTimeout(this.closeTimeout) + this.closeTimeout = setTimeout(() => { + this.syncHiddenToInput() + this.closeList() + }, 100) + } + + choose (event) { + const button = event.target.closest('button[data-value]') + if (!button) return + + event.preventDefault() + this.setValue({ + label: button.dataset.label, + value: button.dataset.value + }) + this.closeList() + } + + renderList (items) { + this.listTarget.innerHTML = '' + + if (items.length === 0) { + const empty = document.createElement('div') + empty.className = 'px-3 py-2 text-xs text-gray-500' + empty.textContent = 'No matches' + this.listTarget.appendChild(empty) + return + } + + items.forEach(option => { + const button = document.createElement('button') + button.type = 'button' + button.className = 'block w-full text-left px-3 py-2 text-xs text-gray-700 hover:bg-gray-100' + button.dataset.value = option.value + button.dataset.label = option.label + button.textContent = option.label + this.listTarget.appendChild(button) + }) + } + + openList () { + this.listTarget.hidden = false + } + + closeList () { + this.listTarget.hidden = true + } + + syncHiddenToInput () { + const query = this.inputTarget.value.trim().toLowerCase() + if (!query) { + this.hiddenTarget.value = '' + return + } + + const match = this.allOptions.find(option => option.label.toLowerCase() === query) + if (match) { + this.hiddenTarget.value = match.value + } else { + this.hiddenTarget.value = '' + } + } + + setValue ({ label, value }) { + this.inputTarget.value = label + this.hiddenTarget.value = value + } + + normalizeOptions (options) { + const normalized = options.map(([label, value]) => ({ + label: String(label), + value: value == null ? '' : String(value) + })) + + normalized.unshift({ label: 'Unassigned', value: '' }) + return normalized + } +} diff --git a/app/views/admin/streams/_spreadsheet_row.html.erb b/app/views/admin/streams/_spreadsheet_row.html.erb index fc1c422..00ae1ba 100644 --- a/app/views/admin/streams/_spreadsheet_row.html.erb +++ b/app/views/admin/streams/_spreadsheet_row.html.erb @@ -5,9 +5,9 @@ data-label="Streamer" data-column="streamer" data-stream-table-preferences-target="column"> -