PageSourceSearch

https://www.lombardchamber.com/assets/controllers/business_link_field_controller-589a7bdb.js

js lombardchamber.com collected 2026-09-24 12:45:22 UTC 7,891 bytes, 238 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// One smart "Business name" field for new leads and deals: type freely for a
4// brand-new business, or pick a suggested existing organization to link to it.
5// Suggestions come from the server (searchUrlValue) so every organization is
6// reachable, archived ones labeled. Editing the name after picking clears the
7// link again. Keyboard: arrows move through suggestions, Enter picks, Escape
8// closes. When contactSelect/contactOptionsUrlValue are present, picking an
9// organization also offers its linked people as an optional contact choice.
10export default class extends Controller {
11  static targets = ["input", "hidden", "panel", "status", "contactField", "contactSelect"]
12  static values = { searchUrl: String, contactOptionsUrl: String }
13
14  connect() {
15    this.linkedName = null
16    this.activeIndex = -1
17    this.boundClickAway = this.clickAway.bind(this)
18    this.boundCancelPendingLookups = this.cancelPendingLookups.bind(this)
19    document.addEventListener("pointerdown", this.boundClickAway)
20    this.element.addEventListener("submit", this.boundCancelPendingLookups)
21  }
22
23  disconnect() {
24    document.removeEventListener("pointerdown", this.boundClickAway)
25    this.element.removeEventListener("submit", this.boundCancelPendingLookups)
26    this.cancelPendingLookups()
27  }
28
29  // Once the form submits, pending lookups must not reach the server: a
30  // request landing between the submission and its redirect would consume
31  // the flash notice meant for the next page.
32  cancelPendingLookups() {
33    clearTimeout(this.searchTimer)
34    this.searchAbort?.abort()
35    this.contactAbort?.abort()
36  }
37
38  search() {
39    const query = this.inputTarget.value.trim()
40
41    if (this.hiddenTarget.value && query !== this.linkedName) this.clearLink()
42
43    clearTimeout(this.searchTimer)
44    if (query.length < 2) {
45      this.hidePanel()
46      return
47    }
48
49    this.searchTimer = setTimeout(() => this.fetchMatches(query), 250)
50  }
51
52  async fetchMatches(query) {
53    this.searchAbort = new AbortController()
54    try {
55      const url = new URL(this.searchUrlValue, window.location.origin)
56      url.searchParams.set("q", query)
57      const response = await fetch(url.toString(), {
58        headers: { "Accept": "application/json" },
59        signal: this.searchAbort.signal
60      })
61      if (!response.ok) {
62        this.hidePanel()
63        return
64      }
65      const matches = await response.json()
66      if (this.inputTarget.value.trim() !== query) return
67
68      if (matches.length === 0 || (matches.length === 1 && matches[0][0] === this.linkedName)) {
69        this.hidePanel()
70        return
71      }
72      this.renderPanel(matches)
73    } catch {
74      this.hidePanel()
75    }
76  }
77
78  keydown(event) {
79    if (event.key === "Escape") {
80      this.hidePanel()
81      return
82    }
83
84    const open = !this.panelTarget.classList.contains("hidden")
85    if (!open) return
86
87    const options = [...this.panelTarget.querySelectorAll("[role='option']")]
88    if (options.length === 0) return
89
90    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
91      event.preventDefault()
92      const step = event.key === "ArrowDown" ? 1 : -1
93      this.activeIndex = (this.activeIndex + step + options.length) % options.length
94      this.paintActive(options)
95    } else if (event.key === "Enter" && this.activeIndex >= 0) {
96      event.preventDefault()
97      options[this.activeIndex].click()
98    }
99  }
100
101  pick(event) {
102    const { id, name } = event.currentTarget.dataset
103    this.cancelPendingLookups()
104    this.hiddenTarget.value = id
105    this.inputTarget.value = name
106    this.linkedName = name
107    this.hidePanel()
108    this.renderStatus()
109    this.loadContactOptions(id)
110  }
111
112  unlink(event) {
113    event.preventDefault()
114    this.clearLink()
115  }
116
117  clearLink() {
118    this.hiddenTarget.value = ""
119    this.linkedName = null
120    this.renderStatus()
121    this.resetContactField()
122  }
123
124  clickAway(event) {
125    if (!this.element.contains(event.target)) this.hidePanel()
126  }
127
128  renderPanel(matches) {
129    const panel = this.panelTarget
130    panel.textContent = ""
131    this.activeIndex = -1
132
133    const heading = document.createElement("div")
134    heading.className = "px-3 pt-2 pb-1 text-[11px] font-medium text-zinc-400"
135    heading.textContent = "Link an existing organization"
136    panel.appendChild(heading)
137
138    matches.forEach(([name, id], index) => {
139      const button = document.createElement("button")
140      button.type = "button"
141      button.role = "option"
142      button.id = `${panel.id}_option_${index}`
143      button.dataset.id = id
144      button.dataset.name = name
145      button.dataset.action = "click->business-link-field#pick"
146      button.className = "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-zinc-800 hover:bg-zinc-50"
147      button.textContent = name
148      panel.appendChild(button)
149    })
150
151    panel.classList.remove("hidden")
152    this.inputTarget.setAttribute("aria-expanded", "true")
153  }
154
155  paintActive(options) {
156    options.forEach((option, index) => {
157      const active = index === this.activeIndex
158      option.classList.toggle("bg-zinc-100", active)
159      option.setAttribute("aria-selected", active ? "true" : "false")
160    })
161    const activeOption = options[this.activeIndex]
162    if (activeOption) {
163      this.inputTarget.setAttribute("aria-activedescendant", activeOption.id)
164      activeOption.scrollIntoView({ block: "nearest" })
165    }
166  }
167
168  // Offers the picked organization's people as an optional contact choice.
169  // Stays hidden when nobody is linked: the automatic default applies.
170  async loadContactOptions(organizationId) {
171    if (!this.hasContactSelectTarget || !this.contactOptionsUrlValue) return
172
173    this.contactAbort = new AbortController()
174    try {
175      const url = new URL(this.contactOptionsUrlValue, window.location.origin)
176      url.searchParams.set("organization_id", organizationId)
177      const response = await fetch(url.toString(), {
178        headers: { "Accept": "application/json" },
179        signal: this.contactAbort.signal
180      })
181      if (!response.ok) return
182      const people = await response.json()
183      if (this.hiddenTarget.value !== String(organizationId)) return
184      if (people.length === 0) return
185
186      this.rebuildContactSelect(people)
187      this.contactSelectTarget.disabled = false
188      this.contactFieldTarget.classList.remove("hidden")
189    } catch { /* choosing a contact is optional; the default applies */ }
190  }
191
192  resetContactField() {
193    if (!this.hasContactSelectTarget) return
194
195    this.rebuildContactSelect([])
196    this.contactSelectTarget.disabled = true
197    this.contactFieldTarget.classList.add("hidden")
198  }
199
200  rebuildContactSelect(people) {
201    const select = this.contactSelectTarget
202    select.textContent = ""
203    select.add(new Option("Automatic (primary contact)", "auto", true, true))
204    select.add(new Option("No contact", ""))
205    people.forEach(([label, id]) => select.add(new Option(label, id)))
206  }
207
208  hidePanel() {
209    this.panelTarget.classList.add("hidden")
210    this.activeIndex = -1
211    this.inputTarget.setAttribute("aria-expanded", "false")
212    this.inputTarget.removeAttribute("aria-activedescendant")
213  }
214
215  renderStatus() {
216    const status = this.statusTarget
217    status.textContent = ""
218
219    if (!this.hiddenTarget.value) {
220      status.classList.add("hidden")
221      return
222    }
223
224    const text = document.createElement("span")
225    text.className = "text-emerald-700"
226    text.textContent = "Will be linked to this existing organization. "
227    status.appendChild(text)
228
229    const unlink = document.createElement("button")
230    unlink.type = "button"
231    unlink.dataset.action = "click->business-link-field#unlink"
232    unlink.className = "font-medium text-zinc-500 underline hover:text-zinc-700"
233    unlink.textContent = "Don't link"
234    status.appendChild(unlink)
235
236    status.classList.remove("hidden")
237  }
238}

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.