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.