1import { Controller } from '@hotwired/stimulus' 2 3/* global Turbo, requestAnimationFrame */ 4 5// A long paste would otherwise open one request per serial at once 6const MAX_CONCURRENT_SEARCHES = 6 7 8// Connects to data-controller='org--multi-search' 9export default class extends Controller { 10 static targets = ['textarea', 'button', 'serialChips', 'results', 'searchAll', 'searchAllHint'] 11 static values = { url: String, searchKind: String, serialsPlaceholder: String, stickersPlaceholder: String, serialsButton: String, stickersButton: String, emptyClass: String, successClass: String, grayClass: String, errorClass: String, errorTooltip: String, spinner: String } 12 13 connect () { 14 if (this.searching) return 15 this.onPopState = () => this.syncFromUrl() 16 window.addEventListener('popstate', this.onPopState) 17 // Renders disabled: clicking before connect submits the form and reloads 18 this.buttonTarget.disabled = false 19 this.syncFromUrl({ preserveInput: true }) 20 } 21 22 disconnect () { 23 window.removeEventListener('popstate', this.onPopState) 24 } 25 26 // Restore the form + results from the URL, on initial load and on back/forward. 27 // The URL already reflects this state, so don't push another history entry. 28 syncFromUrl ({ preserveInput = false } = {}) { 29 const params = new URL(window.location).searchParams 30 const kind = params.get('search_kind') === 'stickers' ? 'stickers' : 'serials' 31 if (this.searchKindValue !== kind) this.applyKind(kind) 32 if (this.hasSearchAllTarget && !this.searchAllTarget.disabled) { 33 this.searchAllTarget.checked = params.get('search_all') === '1' 34 }
35 this.syncSearchAll() 36 const serialsParam = params.get('serials') || '' 37 // On connect, keep anything typed while the controller was still loading 38 if (!preserveInput || serialsParam) this.textareaTarget.value = serialsParam 39 const serials = this.parseSerials(serialsParam) 40 if (serials.length) { 41 this.search(serials, { pushHistory: false }) 42 } else { 43 this.resultsTarget.innerHTML = '' 44 this.serialChipsTarget.innerHTML = '' 45 } 46 } 47 48 switchKind (event) { 49 const value = event.target.value 50 if (this.searchKindValue === value) return 51 this.applyKind(value) 52 this.resultsTarget.innerHTML = '' 53 this.serialChipsTarget.innerHTML = '' 54 55 const url = new URL(window.location.pathname, window.location.origin) 56 url.searchParams.set('search_kind', value) 57 window.history.pushState({}, '', url) 58 } 59 60 applyKind (kind) { 61 this.searchKindValue = kind 62 this.updatePlaceholderAndButton() 63 const radio = this.element.querySelector(`input[name='search_kind'][value='${kind}']`) 64 if (radio) radio.checked = true
65 this.syncSearchAll() 66 } 67 68 updatePlaceholderAndButton () { 69 this.textareaTarget.placeholder = this[`${this.searchKindValue}PlaceholderValue`] 70 this.buttonTarget.textContent = this[`${this.searchKindValue}ButtonValue`] 71 } 72 73 // Sticker search always spans every organization, so lock "search all" on while it's active 74 syncSearchAll () { 75 if (!this.hasSearchAllTarget) return 76 if (this.searchKindValue === 'stickers') { 77 this.searchAllTarget.checked = true 78 this.searchAllTarget.disabled = true 79 this.searchAllHintTarget.hidden = false 80 } else if (this.searchAllTarget.disabled) { 81 this.searchAllTarget.checked = false 82 this.searchAllTarget.disabled = false 83 this.searchAllHintTarget.hidden = true 84 } 85 } 86 87 submit (event) { 88 event.preventDefault() 89 const serials = this.parseSerials(this.textareaTarget.value) 90 if (!serials.length) return 91 this.search(serials) 92 } 93 94 get searchAll () { 95 return this.hasSearchAllTarget && this.searchAllTarget.checked 96 } 97 98 parseSerials (text) { 99 return [...new Set( 100 text.split(/[,\n]/).map(s => s.trim()).filter(s => s) 101 )] 102 } 103 104 async search (serials, { pushHistory = true } = {}) { 105 this.searching = true 106 if (pushHistory) { 107 const url = new URL(window.location.pathname, window.location.origin) 108 url.searchParams.set('serials', serials.join(',')) 109 if (this.searchKindValue === 'stickers') { 110 url.searchParams.set('search_kind', 'stickers') 111 } 112 if (this.searchAll) { 113 url.searchParams.set('search_all', '1') 114 } else { 115 url.searchParams.delete('search_all') 116 } 117 window.history.pushState({}, '', url) 118 } 119 120 this.resultsTarget.innerHTML = '' 121 this.renderPlaceholderChips(serials) 122 this.buttonTarget.disabled = true 123 124 await this.searchAllItems(serials) 125 126 // Wait a frame for Turbo stream DOM updates to complete 127 await new Promise(resolve => requestAnimationFrame(resolve)) 128 this.sortAndFilterResults() 129 // Trigger column settings to apply stored column visibility to new tables 130 this.element.dispatchEvent(new Event('turbo:frame-render', { bubbles: true })) 131 this.alignTableColumns() 132 window.timeLocalizer?.localize() 133 this.buttonTarget.disabled = false 134 this.searching = false 135 } 136 137 async searchAllItems (serials) { 138 for (let start = 0; start < serials.length; start += MAX_CONCURRENT_SEARCHES) { 139 await Promise.all(serials.slice(start, start + MAX_CONCURRENT_SEARCHES) 140 .map((serial, offset) => this.searchItem(serial, start + offset))) 141 } 142 } 143 144 alignTableColumns () { 145 const tables = Array.from(this.resultsTarget.querySelectorAll('.multi-search-serial-result table.ui-table')) 146 if (tables.length < 2) return 147 148 tables.forEach(table => { 149 table.querySelectorAll('thead th').forEach(th => { th.style.minWidth = '' }) 150 }) 151 152 const maxWidths = [] 153 tables.forEach(table => { 154 table.querySelectorAll('thead th').forEach((th, i) => { 155 if (th.offsetWidth > (maxWidths[i] || 0)) maxWidths[i] = th.offsetWidth 156 }) 157 }) 158 159 tables.forEach(table => { 160 table.querySelectorAll('thead th').forEach((th, i) => { 161 if (maxWidths[i]) th.style.minWidth = `${maxWidths[i]}px` 162 }) 163 }) 164 } 165 166 renderPlaceholderChips (serials) { 167 this.serialChipsTarget.innerHTML = '' 168 serials.forEach((serial, index) => { 169 const chip = document.createElement('span') 170 chip.id = `chip_${index}` 171 chip.className = this.emptyClassValue 172 chip.appendChild(this.serialSpan(serial)) 173 chip.insertAdjacentHTML('beforeend', this.spinnerValue) 174 this.serialChipsTarget.appendChild(chip) 175 }) 176 } 177 178 serialSpan (serial) { 179 const span = document.createElement('span') 180 span.className = 'serial-span tw:mr-3' 181 span.textContent = serial.toUpperCase() 182 return span 183 } 184 185 // Drop results the component flagged empty (no exact matches and no close serials). 186 sortAndFilterResults () { 187 const results = Array.from(this.resultsTarget.querySelectorAll('.multi-search-serial-result')) 188 results 189 .sort((a, b) => parseInt(a.dataset.serialIndex) - parseInt(b.dataset.serialIndex)) 190 .forEach(result => { 191 if (result.dataset.hasResults === 'true') { 192 this.resultsTarget.appendChild(result) 193 } else { 194 result.remove() 195 } 196 }) 197 } 198 199 async searchItem (query, index) { 200 const stickers = this.searchKindValue === 'stickers' 201 const url = new URL(this.urlValue, window.location.origin) 202 url.searchParams.set(stickers ? 'query' : 'serial', query) 203 url.searchParams.set('chip_id', `chip_${index}`) 204 if (stickers) { 205 url.searchParams.set('search_kind', 'stickers') 206 } else if (this.searchAll) { 207 url.searchParams.set('search_all', '1') 208 } 209 210 try { 211 const response = await fetch(url, { 212 headers: { Accept: 'text/vnd.turbo-stream.html' } 213 }) 214 215 if (response.ok) {
216 Turbo.renderStreamMessage(await response.text()) 217 } else { 218 this.showChipError(query, index, `Server error ${response.status}`) 219 } 220 } catch { 221 this.showChipError(query, index, 'Network error') 222 } 223 } 224 225 showChipError (serial, index, message) { 226 const chip = document.getElementById(`chip_${index}`) 227 if (!chip) return 228 chip.className = this.errorClassValue 229 chip.innerHTML = '' 230 chip.appendChild(this.serialSpan(serial)) 231 chip.insertAdjacentHTML('beforeend', this.errorTooltipValue.replace('__MESSAGE__', message)) 232 } 233};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.