1import { Controller } from "@hotwired/stimulus" 2 3// Unified auto-submitting GET filter form for staff tables (see filter_bar). 4// 5// - selects submit immediately; search inputs debounce 300ms; dates/numbers 500ms 6// - submissions never overlap: while one is in flight the latest intent is 7// queued and submitted on turbo:submit-end, so responses cannot land out 8// of order 9// - before every submit, the hidden "preserved" params are rebuilt from 10// window.location.search (kept current by data-turbo-action="advance"), so 11// sort/direction and deep-link scopes changed by frame navigation after this 12// form rendered are never resubmitted stale 13// - Clear resets every control to its data-filter-default and resubmits; its 14// visibility and the controls' data-filter-active styling are re-synced on 15// every submit and on connect (heals Turbo snapshot restores) 16export default class extends Controller { 17 static targets = ["indicator", "preserved", "clear", "control"] 18 static values = { 19 delay: { type: Number, default: 300 }, 20 resetParams: { type: Array, default: [] } 21 } 22 23 connect() { 24 this.timer = null 25 this.busyTimer = null 26 this.inflight = false 27 this.queued = false 28 29 this.onSubmit = () => { 30 this.syncPreservedParams() 31 this.syncActiveState() 32 this.disableEmptyControls() 33 } 34 this.onSubmitStart = () => { 35 this.inflight = true 36 this.reenableEmptyControls() 37 this.setBusy(true) 38 } 39 this.onSubmitEnd = () => { 40 this.inflight = false 41 this.setBusy(false) 42 if (this.queued) { 43 this.queued = false 44 this.submitNow() 45 } 46 } 47 48 // Turbo also snapshots during frame navigation, while this form stays 49 // connected. Clean the snapshot's visual state without dropping newer 50 // typing or queued filters; disconnect cancels timers when leaving the page. 51 this.onBeforeCache = () => { 52 if (this.busyTimer) clearTimeout(this.busyTimer) 53 this.reenableEmptyControls() 54 if (this.hasIndicatorTarget) this.indicatorTarget.classList.add("hidden") 55 } 56 57 this.element.addEventListener("submit", this.onSubmit) 58 this.element.addEventListener("turbo:submit-start", this.onSubmitStart) 59 this.element.addEventListener("turbo:submit-end", this.onSubmitEnd) 60 document.addEventListener("turbo:before-cache", this.onBeforeCache) 61 62 // The URL is the source of truth after data-turbo-action=advance; snapshot 63 // restores (back/forward) can revive stale control values. 64 this.syncFromUrl() 65 this.syncActiveState() 66 } 67 68 disconnect() { 69 if (this.timer) clearTimeout(this.timer) 70 if (this.busyTimer) clearTimeout(this.busyTimer) 71 this.element.removeEventListener("submit", this.onSubmit) 72 this.element.removeEventListener("turbo:submit-start", this.onSubmitStart) 73 this.element.removeEventListener("turbo:submit-end", this.onSubmitEnd) 74 document.removeEventListener("turbo:before-cache", this.onBeforeCache) 75 this.reenableEmptyControls() 76 if (this.hasIndicatorTarget) this.indicatorTarget.classList.add("hidden") 77 } 78 79 // Wired per control type by FilterBarBuilder: 80 change() { this.submitNow() } // selects: a change is committed intent 81 input() { this.debounce(this.delayValue) } // search text 82 inputDebounced() { this.debounce(500) } // number inputs 83 changeDebounced() { this.debounce(500) } // date inputs (change = complete date) 84 85 clear() { 86 this.controlTargets.forEach((control) => { 87 control.value = control.dataset.filterDefault ?? "" 88 this.notifySynced(control) 89 }) 90 this.submitNow() 91 } 92 93 debounce(ms) { 94 if (this.timer) clearTimeout(this.timer) 95 this.timer = setTimeout(() => this.submitNow(), ms) 96 } 97 98 submitNow() { 99 if (this.timer) { 100 clearTimeout(this.timer) 101 this.timer = null 102 } 103 if (this.inflight) { 104 this.queued = true 105 return 106 } 107 this.element.requestSubmit() // fires "submit" -> onSubmit syncs state 108 } 109 110 // The toolbar lives outside the results frame and is never re-rendered by 111 // frame navigation; rebuild the auto-preserved hidden params from the URL so 112 // they are always current. Runs synchronously in the submit event, before 113 // Turbo serializes the form. 114 syncPreservedParams() { 115 if (!this.hasPreservedTarget) return 116 117 const owned = new Set(["page", "commit", ...this.resetParamsValue]) 118 for (const control of this.element.elements) { 119 if (control.name && !this.preservedTarget.contains(control)) owned.add(control.name) 120 } 121 122 this.preservedTarget.innerHTML = "" 123 for (const [name, value] of new URLSearchParams(window.location.search)) { 124 if (owned.has(name) || value === "") continue 125 const input = document.createElement("input") 126 input.type = "hidden" 127 input.name = name 128 input.value = value 129 this.preservedTarget.appendChild(input) 130 } 131 } 132 133 // Keep URLs canonical: blank controls are excluded from serialization
134 // (mirrors filtered_path's compact_blank). Runs in the submit event, before 135 // Turbo builds the FormData; controls are re-enabled on turbo:submit-start 136 // (after serialization) so the UI never sees them disabled. 137 disableEmptyControls() { 138 this.emptyControls = Array.from(this.element.elements).filter( 139 (control) => control.name && !control.disabled && control.value === "" 140 ) 141 this.emptyControls.forEach((control) => { control.disabled = true }) 142 } 143 144 reenableEmptyControls() { 145 if (!this.emptyControls) return 146 this.emptyControls.forEach((control) => { control.disabled = false }) 147 this.emptyControls = null 148 } 149 150 syncFromUrl() { 151 const params = new URLSearchParams(window.location.search) 152 this.controlTargets.forEach((control) => { 153 const value = params.get(control.name) 154 if (value === null || value === "") { 155 control.value = control.dataset.filterDefault ?? "" 156 this.notifySynced(control) 157 return 158 } 159 // The server sanitizes params (allowlists, clamps); don't write raw URL 160 // values the control can't represent â keep the server-rendered value. 161 const menuValues = this.menuOptionValues(control) 162 if (menuValues) { 163 if (menuValues.includes(value)) control.value = value 164 } else if (control.type === "number") { 165 const numeric = Number(value) 166 if (!Number.isFinite(numeric)) return 167 const min = control.min === "" ? -Infinity : Number(control.min) 168 const max = control.max === "" ? Infinity : Number(control.max) 169 control.value = String(Math.min(Math.max(numeric, min), max)) 170 } else { 171 control.value = value 172 } 173 this.notifySynced(control) 174 }) 175 } 176 177 // Filter-menu pills use a hidden input; its legal values are the menu's 178 // option rows. Returns null for non-menu controls. 179 menuOptionValues(control) { 180 const menu = control.closest('[data-controller~="filter-menu"]') 181 if (!menu) return null 182 return Array.from(menu.querySelectorAll('[data-filter-menu-target="option"]')) 183 .map((option) => option.dataset.value ?? "") 184 } 185 186 // Tell the owning filter-menu (if any) to re-render its trigger after this 187 // controller rewrote the hidden input directly. 188 notifySynced(control) { 189 control.dispatchEvent(new CustomEvent("filter-form:synced", { bubbles: true })) 190 } 191 192 syncActiveState() { 193 let active = false 194 this.controlTargets.forEach((control) => { 195 const isActive = (control.value ?? "") !== (control.dataset.filterDefault ?? "") 196 control.dataset.filterActive = isActive 197 if (isActive) active = true 198 }) 199 if (this.hasClearTarget) this.clearTarget.hidden = !active 200 } 201 202 setBusy(busy) { 203 if (!this.hasIndicatorTarget) return 204 if (this.busyTimer) clearTimeout(this.busyTimer) 205 if (busy) { 206 // Avoid a distracting flicker on fast requests. 207 this.busyTimer = setTimeout(() => this.indicatorTarget.classList.remove("hidden"), 120) 208 } else { 209 this.indicatorTarget.classList.add("hidden") 210 } 211 } 212}
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.