Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/admin/streams_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions app/helpers/admin/streams_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand All @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
96 changes: 96 additions & 0 deletions app/javascript/controllers/reassign_dropdown_controller.js
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
108 changes: 108 additions & 0 deletions app/javascript/controllers/streamer_autocomplete_controller.js
Original file line number Diff line number Diff line change
@@ -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
}
}
62 changes: 46 additions & 16 deletions app/views/admin/streams/_spreadsheet_row.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,36 +5,66 @@
data-label="Streamer"
data-column="streamer"
data-stream-table-preferences-target="column">
<div class="flex items-start gap-3">
<div class="relative flex items-start gap-3">
<% streamer_platforms = stream.streamer&.platforms || [] %>
<div>
<div class="min-w-0">
<div id="stream_<%= stream.id %>_streamer_name" class="text-sm font-medium text-gray-900">
<%= stream.streamer&.name || 'Unknown' %>
</div>
<div id="stream_<%= stream.id %>_streamer_platform" class="text-sm text-gray-500">
<%= streamer_platforms.any? ? streamer_platforms.join(", ") : "N/A" %>
</div>
</div>
<details class="mt-1 group">
<summary class="cursor-pointer select-none text-gray-400 hover:text-gray-600 group-open:text-gray-700 list-none [&::-webkit-details-marker]:hidden [&::marker]:content-['']" title="Reassign streamer">
<div class="mt-1 relative isolate z-30" data-controller="reassign-dropdown">
<button type="button"
class="cursor-pointer select-none text-gray-400 hover:text-gray-600"
title="Reassign streamer"
aria-expanded="false"
data-reassign-dropdown-target="trigger"
data-action="reassign-dropdown#toggle">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7h5m0 0v5m0-5l-6 6M8 17H3m0 0v-5m0 5l6-6" />
</svg>
<span class="sr-only">Reassign streamer</span>
</summary>
<div class="mt-2">
<%= form_with model: [:admin, stream], class: "flex items-center gap-2" do |form| %>
<%= hidden_field_tag :context, "spreadsheet" %>
<%= form.select :streamer_id,
options_for_select(streamer_options, stream.streamer_id),
{ include_blank: "Unassigned" },
class: "text-xs rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500",
id: "stream_#{stream.id}_streamer_select" %>
<%= form.submit "Save", class: "px-2 py-1 text-xs rounded-md bg-indigo-600 text-white hover:bg-indigo-700" %>
<% end %>
</button>
<div class="mt-2 md:absolute md:left-0 md:top-full md:z-40 pointer-events-auto"
data-reassign-dropdown-target="panel"
data-action="pointerdown->reassign-dropdown#keepOpen"
hidden>
<div class="w-64 max-w-xs rounded-md border border-gray-200 bg-white p-2 shadow-lg pointer-events-auto">
<%= form_with model: [:admin, stream], class: "flex flex-col gap-2" do |form| %>
<%= hidden_field_tag :context, "spreadsheet" %>
<% current_streamer_label = streamer_label_for(stream.streamer_id) %>
<div data-controller="streamer-autocomplete"
data-streamer-autocomplete-options-value="<%= json_escape(streamer_options.to_json) %>">
<%= form.hidden_field :streamer_id,
value: stream.streamer_id,
id: "stream_#{stream.id}_streamer_id",
data: { streamer_autocomplete_target: "hidden" } %>
<%= text_field_tag "streamer_search_#{stream.id}",
current_streamer_label,
id: "stream_#{stream.id}_streamer_select",
placeholder: "Search streamer...",
autocomplete: "off",
spellcheck: false,
class: "w-full text-xs rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500",
data: {
streamer_autocomplete_target: "input",
action: "input->streamer-autocomplete#filter focus->streamer-autocomplete#show blur->streamer-autocomplete#scheduleClose pointerdown->reassign-dropdown#keepOpen click->reassign-dropdown#keepOpen"
} %>
<div class="mt-1 max-h-48 overflow-y-auto rounded-md border border-gray-200 bg-white shadow-sm"
data-streamer-autocomplete-target="list"
data-action="mousedown->streamer-autocomplete#choose pointerdown->reassign-dropdown#keepOpen click->reassign-dropdown#keepOpen"
hidden></div>
</div>
<div class="flex justify-end">
<%= form.submit "Save", class: "px-2 py-1 text-xs rounded-md bg-indigo-600 text-white hover:bg-indigo-700" %>
</div>
<% end %>
</div>
</div>
</details>
</div>
</div>
</td>

Expand Down