PageSourceSearch

https://bikeindex.org/assets/controllers/org/search_controller-ec4…0540330bcd91d8cc002a51eb0ebc329e4c24eb4b.js

js bikeindex.org collected 2026-10-01 10:24:31 UTC 10,217 bytes, 228 lines download raw bytes

1import { Controller } from '@hotwired/stimulus'
2import { collapseField } from 'utils/collapse_utils'
3
4/* global localStorage */
5
6const RESULT_VIEW_KEY = 'orgRegistrationResultView'
7
8// Connects to data-controller='org--search'
9export default class extends Controller {
10  static targets = ['perPage', 'optionalField', 'optionalFieldCheckbox', 'filterSummary', 'periodLabel', 'searchAll', 'searchAllHint', 'locationSearchHint']
11  // What the results rendered as, so a stored preference knows whether it has anything to ask for
12  static values = { resultView: String }
13
14  connect () {
15    this.initOptionalFields(0)
16    this.syncLocationSearch(0)
17    this.syncResultView()
18    document.addEventListener('turbo:frame-render', this.handleFrameRender)
19    document.addEventListener('turbo:before-fetch-request', this.handleFetchRequest)
20    window.addEventListener('search:results-failed', this.stopChartSpinner)
21  }
22
23  disconnect () {
24    document.removeEventListener('turbo:frame-render', this.handleFrameRender)
25    document.removeEventListener('turbo:before-fetch-request', this.handleFetchRequest)
26    window.removeEventListener('search:results-failed', this.stopChartSpinner)
27  }
28
29  // The column panel renders inside the results frame, but the chart is outside it - so it
30  // fetches once the results land, from the address bar they moved.
31  handleFrameRender = (event) => {
32    this.syncResultView()
33    this.syncPeriodLabel()
34    this.syncLocationSearch()
35    this.endSubmitSpinner()
36    if (event.target === this.resultsFrame) this.reloadChart()
37  }
38
39  // The chart spins from the submit, though it waits for the results to fetch. A form
40  // submit's target is the form, so the header is what names the frame. A hover's
41  // prefetch isn't a search yet.
42  handleFetchRequest = (event) => {
43    const { headers } = event.detail.fetchOptions
44    if (!headers['Turbo-Frame'] || headers['X-Sec-Purpose'] === 'prefetch') return
45    if (headers['Turbo-Frame'] !== this.resultsFrame?.id) return
46    const chart = this.searchChart
47    if (!chart || this.chartParams(new URL(event.detail.url, window.location.href)) === this.chartShows(chart)) return
48    chart.setAttribute('busy', '')
49    this.chartAwaitingResults = true
50  }
51
52  // search--form shows the error; the chart stays on the search it has. Only the spinner
53  // set above - Turbo marks the frame busy for its own fetches too.
54  stopChartSpinner = () => {
55    if (!this.chartAwaitingResults) return
56    this.chartAwaitingResults = false
57    this.chartFrame?.removeAttribute('busy')
58  }
59
60  // The view is the server's choice, so restoring the stored one means asking the frame
61  // for it again - only when the address bar names no view, which every search and every
62  // chip leaves it doing.
63  syncResultView () {
64    const params = new URLSearchParams(window.location.search)
65    const inUrl = params.get('search_result_view')
66    if (inUrl) return localStorage.setItem(RESULT_VIEW_KEY, inUrl)
67
68    const stored = localStorage.getItem(RESULT_VIEW_KEY)
69    const frame = this.resultsFrame
70    if (!stored || stored === this.resultViewValue || !frame) return
71
72    params.set('search_result_view', stored)
73    const url = `${window.location.pathname}?${params}`
74    // The address bar moves first, so search--form doesn't read the two as out of step.
75    // Replacing rather than pushing: the rider didn't navigate here.
76    window.history.replaceState(window.history.state, '', url)
77    frame.setAttribute('src', url)
78  }
79
80  // Pages::Org::Search::ChartCard::FRAME_ID - the card also renders where there's no org--search
81  get chartFrame () {
82    return this.element.querySelector('turbo-frame#chart_card_frame')
83  }
84
85  // SharedBlocks::SearchResults::Frame's, which the loading overlay's CSS reaches the same way
86  get resultsFrame () {
87    return this.element.querySelector('.search-results-frame-wrapper > turbo-frame')
88  }
89
90  // The notes and location fields, each named by data-field. One opens if an input in it has
91  // a value, or if it was left open
92  initOptionalFields (duration) {
93    this.optionalFieldTargets.forEach(field => {
94      const hasValue = [...field.querySelectorAll('input')].some(input => input.value.length > 0)
95      const open = hasValue || localStorage.getItem(field.dataset.storageKey) === 'true'
96      this.setOptionalField(field.dataset.field, open, duration)
97    })
98  }
99
100  toggleOptionalField (event) {
101    this.setOptionalField(event.target.dataset.field, event.target.checked)
102  }
103
104  setOptionalField (name, open, duration) {
105    const field = this.optionalFieldTargets.find(target => target.dataset.field === name)
106    if (!field) return
107    collapseField(field, open, duration)
108    localStorage.setItem(field.dataset.storageKey, String(open))
109    const checkbox = this.optionalFieldCheckboxTargets.find(target => target.dataset.field === name)
110    if (checkbox) checkbox.checked = open
111  }
112
113  // BikeServices::OrganizedSearch.location_searchable? - disabled, the fields hide and stop
114  // submitting, but the checkbox keeps whether they were open
115  syncLocationSearch (duration) {
116    const checkbox = this.optionalFieldCheckboxTargets.find(target => target.dataset.field === 'location')
117    const field = this.optionalFieldTargets.find(target => target.dataset.field === 'location')
118    if (!checkbox || !field) return
119
120    const status = document.querySelector('input[type=radio][name=search_status][form="Search_Form"]:checked')?.value
121    const searchAll = this.hasSearchAllTarget && this.searchAllTarget.checked
122    const searchable = JSON.parse(checkbox.dataset.locationableStatuses).includes(status) || (checkbox.dataset.regAddress === 'true' && !searchAll)
123
124    checkbox.disabled = !searchable
125    if (this.hasLocationSearchHintTarget) this.locationSearchHintTarget.hidden = searchable
126    collapseField(field, searchable && checkbox.checked, duration)
127  }
128
129  // Bubbled from any field, so it picks out the one it's for
130  emailChanged (event) {
131    if (event.target.name !== 'search_email' || !this.hasSearchAllTarget) return
132    const hasEmail = event.target.value.trim() !== ''
133    this.searchAllTarget.disabled = hasEmail
134    this.searchAllHintTarget.hidden = !hasEmail
135    if (hasEmail && this.searchAllTarget.checked) {
136      this.searchAllTarget.checked = false
137      this.syncLocationSearch()
138    }
139  }
140
141  filterChanged () {
142    this.syncFilterSummary()
143    // Before the submit, so a disabled location isn't searched
144    this.syncLocationSearch()
145    const form = document.getElementById('Search_Form')
146    if (form) {
147      form.requestSubmit()
148    }
149  }
150
151  // Reads the chips' own markup rather than rebuilding their wording, so the summary can't
152  // drift from the labels. An empty value (or `all`) is a group that isn't filtering.
153  syncFilterSummary () {
154    if (!this.hasFilterSummaryTarget) return
155    const active = [...document.querySelectorAll('input[type=radio][form="Search_Form"]:checked')]
156      // The row above the summary names the date range itself
157      .filter(radio => radio.name !== 'period')
158      .filter(radio => radio.value !== '' && radio.value !== 'all')
159      .map(radio => radio.closest('label')?.querySelector('span')?.innerHTML)
160      .filter(Boolean)
161
162    this.filterSummaryTarget.innerHTML = active.join(' · ')
163    this.filterSummaryTarget.hidden = active.length === 0
164  }
165
166  // The chip the summary leaves out, for the same reason it reads the others' markup.
167  // ui--period-select owns the submit, so this runs off the search it came back from.
168  // Its text, not the span the summary reuses: the server's own label carries the prefix
169  // that the chip hides below md.
170  syncPeriodLabel () {
171    const picked = document.querySelector('input[type=radio][name=period][form="Search_Form"]:checked')
172    if (!this.hasPeriodLabelTarget || !picked) return
173
174    this.periodLabelTarget.textContent = picked.closest('label').textContent.replace(/\s+/g, ' ').trim()
175  }
176
177  // The search submits into a frame, so the page it spun on is still here - register--retry
178  // raises the same event for a submit that ended without going anywhere
179  endSubmitSpinner () {
180    const form = document.getElementById('Search_Form')
181    if (!form) return
182
183    ;[...form.elements].filter(element => element.type === 'submit')
184      .forEach(element => element.dispatchEvent(new Event('spinner:reset')))
185  }
186
187  perPageChanged () {
188    const url = new URL(window.location)
189    url.searchParams.set('per_page', this.perPageTarget.value)
190    url.searchParams.set('search_no_js', 'true')
191    window.location = url.toString()
192  }
193
194  // The card sits outside the results frame, so a search leaves it answering the previous
195  // one. Gated on the search itself having moved, or the first results render would refetch
196  // the chart the frame is already fetching.
197  reloadChart () {
198    this.stopChartSpinner()
199    const chart = this.searchChart
200    if (!chart || this.chartParams() === this.chartShows(chart)) return
201    chart.setAttribute('src', window.location.href)
202  }
203
204  // The chart frame, when its scope is the search - which the card says by rendering the
205  // target. Switching scope navigates the frame itself, so the scope isn't part of a search.
206  get searchChart () {
207    const frame = this.chartFrame
208    if (!frame?.getAttribute('src') || !frame.querySelector('[data-chart-follows-search]')) return null
209    return frame
210  }
211
212  // What the chart is showing: its src is the record of it, holding the search the frame
213  // last asked for, including one still in flight.
214  chartShows (frame) {
215    return this.chartParams(new URL(frame.getAttribute('src'), window.location.href))
216  }
217
218  // The same search, written by a link and by the address bar, differs in order and in
219  // which empty fields it carries - so compare a canonical form. A page turn, a sort, a
220  // per-page change or opening the card returns the same chart, so they're left out too.
221  chartParams (url = window.location) {
222    const params = new URLSearchParams(url.search);
223    ['page', 'sort', 'sort_direction', 'direction', 'per_page', 'search_result_view', 'chart_open', 'chart_scope']
224      .forEach(name => params.delete(name))
225
226    return [...params].filter(([, value]) => value !== '').sort().join('&')
227  }
228};

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.