PageSourceSearch

https://bikeindex.org/assets/controllers/org/multi_search_controll…51f241ece4924eb9c9eb826263c552e3583ee8ca.js

js bikeindex.org collected 2026-09-24 08:54:13 UTC 8,232 bytes, 233 lines download raw bytes

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.