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.