PageSourceSearch

https://www.crawfordcountychamber.org/assets/controllers/filter_form_controller-152e4502.js

js crawfordcountychamber.org collected 2026-10-04 10:21:11 UTC 8,025 bytes, 212 lines download raw bytes

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.