1import { Controller } from "@hotwired/stimulus" 2 3/** 4 * Virtual Table Controller 5 * 6 * Implements virtual scrolling for large tables - only renders visible rows. 7 * Supports live search with debouncing and filter dropdowns. 8 */ 9export default class extends Controller { 10 static targets = [ 11 "viewport", // Scrollable container 12 "content", // Inner content with dynamic height 13 "tbody", // Table body where rows are rendered 14 "searchInput", // Search input field 15 "rowCount", // Element to display row count 16 "loading", // Loading indicator 17 "emptyState", // Empty state message 18 "sortIndicator" // Sort direction indicators on column headers 19 ] 20 21 static values = { 22 rowHeight: { type: Number, default: 73 }, // Height of each row in pixels 23 bufferSize: { type: Number, default: 10 }, // Extra rows to render above/below viewport 24 chunkSize: { type: Number, default: 100 }, // Fetch rows in chunks of this size 25 fetchUrl: String, // URL to fetch rows from 26 debounceMs: { type: Number, default: 300 } // Debounce delay for search 27 } 28 29 connect() { 30 this.totalRows = 0 31 this.cache = new Map() // Cache fetched rows by index 32 this.pendingFetch = null 33 this.fetchController = null // AbortController for fetch requests 34 this.filters = this.getInitialFilters() 35 this.searchTimeout = null 36 this.fetchDebounceTimeout = null 37 this.lastFetchRange = null // Track last fetched range to avoid duplicates 38 39 // Initial data fetch 40 this.fetchMetadata().then(() => { 41 this.render() 42 }) 43 44 this.updateSortIndicators() 45 this.initStatusCheckboxes() 46 47 // Bind scroll handler 48 this.handleScroll = this.handleScroll.bind(this) 49 this.viewportTarget.addEventListener("scroll", this.handleScroll, { passive: true }) 50 } 51 52 disconnect() { 53 this.viewportTarget.removeEventListener("scroll", this.handleScroll) 54 if (this.searchTimeout) clearTimeout(this.searchTimeout) 55 if (this.fetchDebounceTimeout) clearTimeout(this.fetchDebounceTimeout) 56 if (this.fetchController) this.fetchController.abort() 57 } 58 59 // Get initial filters from URL params 60 getInitialFilters() { 61 const params = new URLSearchParams(window.location.search) 62 return { 63 search: params.get("search") || "", 64 brand_id: params.get("brand_id") || "", 65 category_id: params.get("category_id") || "", 66 status: params.has("status") ? params.get("status") : "pending,active,inactive", 67 stock_from: params.get("stock_from") || "", 68 stock_to: params.get("stock_to") || "", 69 price_from: params.get("price_from") || "", 70 price_to: params.get("price_to") || "", 71 is_sample: params.get("is_sample") || "", 72 sort: params.get("sort") || "" 73 } 74 } 75 76 // Build query string from filters 77 buildQueryString(extra = {}) { 78 const params = new URLSearchParams() 79 const allParams = { ...this.filters, ...extra } 80 81 Object.entries(allParams).forEach(([key, value]) => { 82 if (value !== null && value !== undefined && value !== "") { 83 params.set(key, value) 84 } 85 }) 86 87 return params.toString() 88 } 89 90 // Fetch metadata (total count) from server 91 async fetchMetadata() { 92 this.showLoading() 93 94 try { 95 const query = this.buildQueryString({ metadata_only: true }) 96 const response = await fetch(`${this.fetchUrlValue}?${query}`) 97 const data = await response.json() 98 99 this.totalRows = data.total_count 100 this.updateRowCount() 101 this.updateContentHeight() 102 103 if (this.totalRows === 0) { 104 this.showEmptyState() 105 } else { 106 this.hideEmptyState() 107 } 108 } catch (error) { 109 console.error("Failed to fetch metadata:", error) 110 } finally { 111 this.hideLoading() 112 } 113 } 114 115 // Fetch rows for a given range (debounced to avoid rapid requests) 116 fetchRows(startIndex, endIndex) { 117 // Check cache first 118 const uncachedIndices = [] 119 for (let i = startIndex; i <= endIndex; i++) { 120 if (!this.cache.has(i)) { 121 uncachedIndices.push(i) 122 } 123 } 124 125 if (uncachedIndices.length === 0) { 126 return // All rows cached 127 } 128
129 // Expand to chunk boundaries for more efficient fetching 130 const fetchStart = Math.floor(Math.min(...uncachedIndices) / this.chunkSizeValue) * this.chunkSizeValue 131 const fetchEnd = Math.min( 132 this.totalRows - 1, 133 Math.ceil((Math.max(...uncachedIndices) + 1) / this.chunkSizeValue) * this.chunkSizeValue - 1 134 ) 135 const rangeKey = `${fetchStart}-${fetchEnd}` 136 137 // Skip if same range is already being fetched 138 if (this.lastFetchRange === rangeKey && this.pendingFetch) { 139 return 140 } 141 142 // Debounce fetch requests (50ms) to avoid hammering server during fast scroll 143 if (this.fetchDebounceTimeout) { 144 clearTimeout(this.fetchDebounceTimeout) 145 } 146 147 this.fetchDebounceTimeout = setTimeout(() => { 148 this.doFetchRows(fetchStart, fetchEnd, rangeKey) 149 }, 50) 150 } 151 152 // Actually perform the fetch 153 async doFetchRows(fetchStart, fetchEnd, rangeKey) { 154 // Abort any pending fetch 155 if (this.fetchController) { 156 this.fetchController.abort() 157 } 158 159 const controller = new AbortController() 160 this.fetchController = controller 161 this.pendingFetch = true 162 this.lastFetchRange = rangeKey 163 164 try { 165 const query = this.buildQueryString({ 166 start_index: fetchStart, 167 end_index: fetchEnd 168 }) 169 170 const response = await fetch(`${this.fetchUrlValue}?${query}`, { 171 signal: controller.signal 172 }) 173 174 const data = await response.json() 175 176 // Cache the fetched rows 177 data.rows.forEach((row, i) => { 178 this.cache.set(fetchStart + i, row) 179 }) 180 181 // Re-render with new data 182 this.render() 183 } catch (error) { 184 if (error.name !== "AbortError") { 185 console.error("Failed to fetch rows:", error) 186 } 187 } finally { 188 this.pendingFetch = false 189 this.fetchController = null 190 } 191 } 192 193 // Handle scroll events 194 handleScroll() { 195 requestAnimationFrame(() => this.render()) 196 } 197 198 // Calculate visible range and render rows 199 render() { 200 if (this.totalRows === 0) return 201 202 const scrollTop = this.viewportTarget.scrollTop 203 const viewportHeight = this.viewportTarget.clientHeight 204 205 // Calculate visible range 206 const startIndex = Math.max(0, Math.floor(scrollTop / this.rowHeightValue) - this.bufferSizeValue) 207 const visibleCount = Math.ceil(viewportHeight / this.rowHeightValue) 208 const endIndex = Math.min(this.totalRows - 1, startIndex + visibleCount + this.bufferSizeValue * 2) 209 210 // Fetch any missing rows 211 this.fetchRows(startIndex, endIndex) 212 213 // Build HTML for visible rows 214 const rows = [] 215 for (let i = startIndex; i <= endIndex; i++) { 216 const rowData = this.cache.get(i) 217 if (rowData) { 218 rows.push(this.renderRow(rowData, i)) 219 } else { 220 rows.push(this.renderPlaceholderRow(i)) 221 } 222 } 223 224 // Calculate offset for positioning 225 const offsetY = startIndex * this.rowHeightValue 226 227 // Update DOM 228 this.tbodyTarget.style.transform = `translateY(${offsetY}px)` 229 this.tbodyTarget.innerHTML = rows.join("") 230 231 // Trigger lazy image loading for newly rendered rows 232 this.initLazyImages() 233 } 234 235 // Render a single row 236 renderRow(data, index) { 237 const statusBadge = this.getStatusBadge(data.status) 238 const stockBadge = this.getStockBadge(data.stock, data.stock_by_location) 239 const image = data.image_url 240 ? `<div class="relative h-12 w-12 rounded-lg overflow-hidden bg-gray-100"> 241 <img src="${data.image_url}" alt="${this.escapeHtml(data.product_name)}" 242 class="h-12 w-12 object-cover" loading="lazy"> 243 </div>` 244 : `<div class="h-12 w-12 rounded-lg bg-gray-100 flex items-center justify-center"> 245 <svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 246 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/> 247 </svg> 248 </div>` 249 250 return ` 251 <tr class="hover:bg-amber-50" style="height: ${this.rowHeightValue}px;"> 252 <td class="px-3 py-4 w-10"> 253 ${data.variant_id 254 ? `<input type="checkbox" value="${data.variant_id}" 255 data-bulk-status-target="checkbox" 256 data-action="change->bulk-status#toggle"
257 class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500">` 258 : ''} 259 </td> 260 <td class="px-6 py-4 whitespace-nowrap"> 261 <span class="text-sm text-gray-500">${data.variant_id || '-'}</span> 262 </td> 263 <td class="px-2 py-4 whitespace-nowrap w-14">${image}</td> 264 <td class="px-6 py-4"> 265 <div class="text-sm text-gray-900"><span class="font-bold">${this.escapeHtml(data.brand_name)}</span> ${this.escapeHtml(data.product_name)}</div> 266 ${(data.variant_name || data.variant_size) 267 ? `<div class="text-sm text-gray-400">${[data.variant_name, data.variant_size, data.variant_version].filter(Boolean).map(v => this.escapeHtml(v)).join(' - ')}</div>` 268 : ''} 269 </td> 270 <td class="px-6 py-4"> 271 ${data.categories 272 ? `<div class="text-sm text-gray-900">${this.escapeHtml(data.categories)}</div>` 273 : '<span class="text-sm text-gray-400">-</span>'} 274 </td> 275 <td class="px-6 py-4 whitespace-nowrap text-right"> 276 ${data.price !== null 277 ? `<span class="text-sm text-gray-900">$${parseFloat(data.price).toFixed(2)}</span>` 278 : '<span class="text-sm text-gray-400">-</span>'} 279 </td> 280 <td class="px-6 py-4 whitespace-nowrap text-center">${stockBadge}</td> 281 <td class="px-6 py-4 whitespace-nowrap text-center"> 282 <div class="flex items-center justify-center gap-1"> 283 ${statusBadge} 284 ${data.is_sample ? '<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">S</span>' : ''} 285 </div> 286 </td> 287 <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> 288 <div class="flex items-center justify-end space-x-2"> 289 <a href="/products/all/${data.product_id}" class="text-primary-600 hover:text-primary-900" title="View"> 290 <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 291 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/> 292 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> 293 </svg> 294 </a> 295 <a href="/products/all/${data.product_id}/edit" class="text-gray-400 hover:text-gray-500" title="Edit"> 296 <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 297 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"/> 298 </svg> 299 </a> 300 <a href="/products/all/${data.product_id}" data-turbo-method="delete" data-turbo-confirm="Are you sure you want to delete this product?" class="text-gray-400 hover:text-red-500" title="Delete"> 301 <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 302 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/> 303 </svg> 304 </a> 305 </div> 306 </td> 307 </tr> 308 ` 309 } 310 311 // Render placeholder row while loading 312 renderPlaceholderRow(index) { 313 return ` 314 <tr style="height: ${this.rowHeightValue}px;"> 315 <td colspan="9" class="px-6 py-4"> 316 <div class="animate-pulse flex space-x-4"> 317 <div class="h-4 bg-gray-200 rounded w-8"></div> 318 <div class="h-10 w-10 bg-gray-200 rounded"></div> 319 <div class="flex-1 space-y-2 py-1"> 320 <div class="h-4 bg-gray-200 rounded w-3/4"></div> 321 <div class="h-3 bg-gray-200 rounded w-1/2"></div> 322 </div> 323 </div> 324 </td> 325 </tr> 326 ` 327 } 328 329 getStatusBadge(status) { 330 const badges = { 331 active: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>', 332 pending: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">Pending</span>', 333 inactive: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Inactive</span>', 334 discontinued: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Discontinued</span>' 335 } 336 return badges[status] || `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">${status}</span>` 337 } 338 339 getStockBadge(stock, stockByLocation) { 340 const tooltipContent = stockByLocation && stockByLocation.length > 0 341 ? stockByLocation.map(l => `<div class="flex justify-between gap-4"><span class="text-gray-300">${this.escapeHtml(l.name)}
341</span><span class="font-medium text-white">${l.stock}</span></div>`).join('') 342 : '' 343 const tooltipHtml = tooltipContent 344 ? `<div class="stock-tooltip">${tooltipContent}</div>` 345 : '' 346 347 if (stock === 0) { 348 return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 cursor-default relative stock-hover-group">Out of Stock${tooltipHtml}</span>` 349 } else if (stock <= 10) { 350 return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 cursor-default relative stock-hover-group">${stock}${tooltipHtml}</span>` 351 } 352 return `<span class="text-sm text-gray-900 cursor-default relative stock-hover-group">${stock}${tooltipHtml}</span>` 353 } 354 355 // Update the total content height for proper scrollbar 356 updateContentHeight() { 357 const totalHeight = this.totalRows * this.rowHeightValue 358 this.contentTarget.style.height = `${totalHeight}px` 359 } 360 361 // Update row count display 362 updateRowCount() { 363 if (this.hasRowCountTarget) { 364 this.rowCountTarget.textContent = `${this.totalRows.toLocaleString()} rows` 365 } 366 } 367 368 // Search input handler with debounce 369 search(event) { 370 const value = event.target.value 371 372 if (this.searchTimeout) { 373 clearTimeout(this.searchTimeout) 374 } 375 376 this.searchTimeout = setTimeout(() => { 377 this.filters.search = value 378 this.resetAndRefetch() 379 this.updateURL() 380 }, this.debounceMsValue) 381 } 382 383 // Filter change handlers 384 filterByBrand(event) { 385 this.filters.brand_id = event.target.value 386 this.resetAndRefetch() 387 this.updateURL() 388 } 389 390 filterByCategory(event) { 391 this.filters.category_id = event.target.value 392 this.resetAndRefetch() 393 this.updateURL() 394 } 395 396 filterByStatus() { 397 const checked = this.element.querySelectorAll('[data-status-checkbox]:checked') 398 this.filters.status = Array.from(checked).map(cb => cb.value).join(',') 399 this.updateStatusLabel() 400 this.resetAndRefetch() 401 this.updateURL() 402 } 403 404 initStatusCheckboxes() { 405 const active = this.filters.status ? this.filters.status.split(',') : [] 406 this.element.querySelectorAll('[data-status-checkbox]').forEach(cb => { 407 cb.checked = active.includes(cb.value) 408 }) 409 this.updateStatusLabel() 410 } 411 412 updateStatusLabel() { 413 const label = this.element.querySelector('[data-status-label]') 414 if (!label) return 415 const checked = this.element.querySelectorAll('[data-status-checkbox]:checked') 416 if (checked.length === 0) { 417 label.textContent = 'All Status' 418 } else if (checked.length === 4) { 419 label.textContent = 'All Status' 420 } else { 421 label.textContent = Array.from(checked).map(cb => cb.dataset.label).join(', ') 422 } 423 } 424 425 filterBySample(event) { 426 this.filters.is_sample = event.target.value 427 this.resetAndRefetch() 428 this.updateURL() 429 } 430 431 sortBy(event) { 432 this.filters.sort = event.target.value 433 this.resetAndRefetch() 434 this.updateURL() 435 this.updateSortIndicators() 436 } 437 438 toggleSort(event) { 439 const key = event.currentTarget.dataset.sortKey 440 const currentSort = this.filters.sort 441 442 // Cycle: none -> asc -> desc -> none 443 if (currentSort === `${key}_asc`) { 444 this.filters.sort = `${key}_desc` 445 } else if (currentSort === `${key}_desc`) { 446 this.filters.sort = "" 447 } else { 448 this.filters.sort = `${key}_asc` 449 } 450 451 // Sync the sort dropdown if present 452 const sortSelect = this.element.querySelector("[data-action*='sortBy']") 453 if (sortSelect) sortSelect.value = this.filters.sort 454 455 this.resetAndRefetch() 456 this.updateURL() 457 this.updateSortIndicators() 458 } 459 460 updateSortIndicators() { 461 if (!this.hasSortIndicatorTarget) return 462 463 this.sortIndicatorTargets.forEach(indicator => { 464 const key = indicator.dataset.sortKey 465 if (this.filters.sort === `${key}_asc`) { 466 indicator.textContent = "â" 467 } else if (this.filters.sort === `${key}_desc`) { 468 indicator.textContent = "â" 469 } else { 470 indicator.textContent = "" 471 } 472 }) 473 } 474 475 // Apply advanced filters 476 applyAdvancedFilters(event) { 477 event.preventDefault() 478 const form = event.target 479 const formData = new FormData(form) 480 481 this.filters.stock_from = formData.get("stock_from") || "" 482 this.filters.stock_to = formData.get("stock_to") || "" 483 this.filters.price_from = formData.get("price_from") || "" 484 this.filters.price_to = formData.get("price_to") || "" 485 486 this.resetAndRefetch() 487 this.updateURL() 488 489 // Close the modal by finding the filter-modal controller on the same element 490 const filterModalController = this.application.getControllerForElementAndIdentifier( 491 this.element, 492 "filter-modal" 493 ) 494 if (filterModalController) { 495 filterModalController.close() 496 } 497 } 498 499 // Clear all filters 500 clearFilters() { 501 this.filters = { 502 search: "", 503 brand_id: "", 504 category_id: "", 505 status: "pending,active,inactive", 506 stock_from: "", 507 stock_to: "", 508 price_from: "", 509 price_to: "", 510 is_sample: "", 511 sort: "" 512 } 513 514 // Clear input fields 515 if (this.hasSearchInputTarget) { 516 this.searchInputTarget.value = "" 517 } 518 519 // Clear all select elements 520 this.element.querySelectorAll("select").forEach(select => { 521 select.value = "" 522 }) 523 524 // Reset status checkboxes to default 525 this.initStatusCheckboxes() 526 527 this.resetAndRefetch() 528 this.updateURL() 529 } 530 531 // Reset scroll and cache, then refetch 532 resetAndRefetch() { 533 this.cache.clear() 534 this.viewportTarget.scrollTop = 0 535 this.fetchMetadata().then(() => { 536 this.render() 537 }) 538 } 539 540 // Update URL without page reload 541 updateURL() { 542 const query = this.buildQueryString() 543 const newURL = query ? `${window.location.pathname}?${query}` : window.location.pathname 544 window.history.replaceState({}, "", newURL) 545 } 546 547 // Show/hide loading indicator 548 showLoading() { 549 if (this.hasLoadingTarget) { 550 this.loadingTarget.classList.remove("hidden") 551 } 552 } 553 554 hideLoading() { 555 if (this.hasLoadingTarget) { 556 this.loadingTarget.classList.add("hidden") 557 } 558 } 559 560 // Show/hide empty state 561 showEmptyState() { 562 if (this.hasEmptyStateTarget) { 563 this.emptyStateTarget.classList.remove("hidden") 564 } 565 this.tbodyTarget.innerHTML = "" 566 } 567 568 hideEmptyState() { 569 if (this.hasEmptyStateTarget) { 570 this.emptyStateTarget.classList.add("hidden") 571 } 572 } 573 574 // Initialize lazy loading for images (simple version - native loading="lazy" handles most cases) 575 initLazyImages() { 576 // The native loading="lazy" attribute handles most cases 577 // This is a hook for any additional lazy loading logic if needed 578 } 579 580 // Utility: Escape HTML to prevent XSS 581 escapeHtml(text) { 582 if (!text) return "" 583 const div = document.createElement("div") 584 div.textContent = text 585 return div.innerHTML 586 } 587}
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.