1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static values = { 5 resultLimit: { type: Number, default: 25 }, 6 typingDelay: { type: Number, default: 300 }, 7 searchPath: { type: String, default: '/products/product_search' }, 8 productIdFieldName: { type: String, default: 'line_items[][product_id]' }, 9 retailProductIdFieldName: { type: String, default: 'line_items[][retail_product_id]' }, 10 // When set, selecting a retail product loads its options into the 11 // "retail-options" frame so staff answer them from dropdowns. 12 retailOptionsPath: { type: String, default: '' } 13 } 14 15 connect() { 16 let typingTimer; 17 18 // Create a hidden lookup div 19 const resultsContainer = document.createElement('div'); 20 resultsContainer.className = "hidden relative"; 21 resultsContainer.id = `lookup-results-${this.element.id}`; 22 this.element.parentNode.appendChild(resultsContainer); 23 this.resultsContainer = resultsContainer; 24 25 this.element.addEventListener('input', (event) => { 26 clearTimeout(typingTimer); 27 typingTimer = setTimeout(() => { 28 this.sendRequest(); 29 }, this.typingDelayValue); 30 }); 31 32 this.element.addEventListener('keydown', (event) => { 33 if (!isNaN(parseInt(event.key))) { 34 const index = parseInt(event.key) - 1; 35 const resultElement = document.getElementById(this.resultsContainer.id + '-result-' + index); 36 if (resultElement) { 37 const selectedId = resultElement.dataset.productId; 38 // Need to find the product object from the last search results 39 const product = this.lastSearchResults && this.lastSearchResults[index]; 40 this.selectItem(selectedId, resultElement.dataset.productName, product); 41 } 42 } 43 if (event.key === 'Tab') { 44 this.hideResults(); 45 } 46 }); 47 48 document.body.addEventListener('click', (event) => { 49 const resultsListElement = document.getElementById(this.resultsContainer.id + '-list'); 50 const inResultsList = resultsListElement && resultsListElement.contains(event.target); 51 const inInput = this.element.contains(event.target); 52 if (!inResultsList && !inInput) { 53 this.hideResults(); 54 } 55 }); 56 57 this.element.addEventListener('focus', () => { 58 this.showResults(); 59 if (this.element.value.length > 0) { 60 this.sendRequest(); 61 } 62 }); 63 } 64 65 sendRequest() { 66 // Get the CSRF token from the meta tag 67 const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); 68 69 // If not in a form, you might want to fetch based on the element's value 70 const query = this.element.value; 71 if (query.length < 2) { 72 this.hideResults(); 73 return; 74 } 75 76 fetch(`${this.searchPathValue}?query=${encodeURIComponent(query)}`, { 77 headers: { 78 'X-Requested-With': 'XMLHttpRequest', 79 'X-CSRF-Token': token 80 } 81 }) 82 .then(response => response.json()) 83 .then(products => { 84 // Store the results for keyboard navigation 85 this.lastSearchResults = products; 86 87 // Clear the container first 88 this.resultsContainer.innerHTML = ''; 89 this.showResults(); 90 91 // Create the container element 92 const ul = document.createElement('ul'); 93 ul.className = "z-10 absolute left-0 my-8 rounded-md border border-gray-300 bg-white p-4 shadow-lg w-[50vw]"; 94 ul.id = this.resultsContainer.id + '-list'; 95 96 // Add each list item 97 products.forEach((product, index) => { 98 const li = document.createElement('li'); 99 li.className = "my-4 flex cursor-pointer items-center justify-between rounded-md border border-gray-200 bg-gray-50 px-4 py-2 shadow-sm group first:mt-0 hover:border-gray-300 hover:shadow-md"; 100 li.id = this.resultsContainer.id + '-result-' + index; 101 102 const label = product.label ?? product.description; 103 const code = product.code ?? product.product_code; 104 const displayName = `${label} (${code})`; 105 const price = (product.price_cents / 100).toFixed(2);
106 const optionsBadge = product.has_options 107 ? '<span class="ml-4 rounded-md bg-indigo-50 px-2 py-1 text-xs font-medium text-indigo-700">options</span>' 108 : ''; 109 110 li.innerHTML = ` 111 <div> 112 <span class="mr-8 text-sm text-gray-400">#${index + 1}</span> 113 <span class="text-lg font-medium">${label}</span> 114 <span class="ml-4 text-gray-600">(${code})</span> 115 <span class="ml-4 text-gray-600">$${price}</span> 116 ${optionsBadge} 117 </div> 118 <div> 119 <span class="hidden group-hover:block">Select</span> 120 </div> 121 `; 122 123 // Add click handler 124 li.addEventListener('click', (event) => { 125 // Stop bubble 126 event.stopPropagation(); 127 this.selectItem(product.id, displayName, product); 128 }); 129 li.dataset.productId = product.id; 130 li.dataset.productName = displayName; 131 li.dataset.specialOrder = product.special_order; 132 133 ul.appendChild(li); 134 }); 135 136 // Add the "Limited results" message 137 const limitSpan = document.createElement('span'); 138 limitSpan.className = "pt-8 text-sm text-gray-600"; 139 limitSpan.textContent = `Limited to ${this.resultLimitValue} results`; 140 ul.appendChild(limitSpan); 141 142 // Add the populated list to the results container 143 this.resultsContainer.appendChild(ul); 144 }) 145 .catch(error => { 146 console.error("Error:", error); 147 }); 148 } 149 150 selectItem(id, displayName, product = null) { 151 const isRetail = product?.sellable_type === 'retail_product'; 152 153 // A line sells exactly one thing, so only the matching catalog's field 154 // carries an id â the other is cleared in case an earlier pick set it. 155 const legacyInput = this.hiddenInput('hidden-product-id', this.productIdFieldNameValue); 156 const retailInput = this.hiddenInput('hidden-retail-product-id', this.retailProductIdFieldNameValue); 157 legacyInput.value = isRetail ? '' : id; 158 retailInput.value = isRetail ? id : ''; 159 160 this.loadRetailOptions(isRetail ? id : null); 161 162 // Set the displayed text to the product name 163 this.element.value = displayName; 164 165 // Emit a custom event for other controllers to listen to 166 if (product) { 167 const event = new CustomEvent('product-selected', { 168 detail: { product: product }, 169 bubbles: true 170 }); 171 this.element.dispatchEvent(event); 172 } 173 174 this.hideResults(); 175 } 176 177 hiddenInput(idPrefix, name) { 178 const id = `${idPrefix}-${this.element.id}`; 179 let input = document.getElementById(id); 180 if (!input) { 181 input = document.createElement('input'); 182 input.type = 'hidden'; 183 input.name = name; 184 input.id = id; 185 this.element.parentNode.appendChild(input); 186 } 187 return input; 188 } 189 190 loadRetailOptions(retailProductId) { 191 if (!this.retailOptionsPathValue) return; 192 193 const frame = document.getElementById('retail-options'); 194 if (!frame) return; 195 196 if (retailProductId) { 197 frame.src = `${this.retailOptionsPathValue}?retail_product_id=${retailProductId}`; 198 } else { 199 frame.removeAttribute('src'); 200 frame.innerHTML = ''; 201 } 202 } 203 204 hideResults() { 205 this.resultsContainer.classList.add('hidden'); 206 this.resultsContainer.innerHTML = ''; 207 } 208 209 showResults() { 210 this.resultsContainer.classList.remove('hidden'); 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.