PageSourceSearch

https://hush.sg/assets/controllers/virtual_table_controller-e78fe6b4.js

js hush.sg collected 2026-09-26 07:30:52 UTC 21,357 bytes, 587 lines download raw bytes

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.