PageSourceSearch

https://dashboard.tastevinapp.com/assets/controllers/beverage_skus…1e451a1b0b8616abaa580eb4bd41fdf6a76759d8.js

js tastevinapp.com collected 2026-10-01 16:08:26 UTC 7,513 bytes, 188 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { startButtonSpinner, stopButtonSpinner } from "lib/button_spinner"
3
4// Phase 8 session 22 (P2.7 finisher) — beverage_skus#stock page behavior.
5//
6// Three responsibilities ported from the legacy inline `:javascript`:
7//   1. Search: click "Search" or press Enter in the search field →
8//      copy `#search_field.value` into `#search_term` and submit
9//      `#beverage_form` (a GET form that round-trips through stock).
10//   2. Bulk delete: read all checked `.stock-delete-checkbox`, build
11//      a comma-joined ID list, confirm with the user, POST to the
12//      bulk_delete endpoint, replace the `#beverage_table_container`
13//      innerHTML with the response. #2417: the delete itself runs in a
14//      background job — queued rows come back grayed out ("Processing…")
15//      and the table is re-polled until they're gone.
16//   3. The legacy overlay (`loadOverStart`/`loadOverStop` jQuery
17//      plugin) is replaced by `aria-busy` on the container — modern
18//      UX leans on the action's existing feedback (button click +
19//      brief network wait).
20//
21// Wiring (on the section body):
22//   data-controller="beverage-skus-stock"
23//   data-beverage-skus-stock-bulk-delete-url-value="/beverage_skus/bulk_delete"
24//   data-beverage-skus-stock-confirm-message-value="<localized>"
25// On the search field:
26//   data-action="keydown->beverage-skus-stock#onSearchKey"
27// On the search button:
28//   data-action="click->beverage-skus-stock#runSearch"
29// On the delete button (lives in the section header `actions:` slot
30// — outside the controller root in DOM order; the legacy `#bulk_delete`
31// id is preserved, and the action explicitly targets this controller
32// via `click->beverage-skus-stock#bulkDelete` which bubbles fine):
33//   data-action="click->beverage-skus-stock#bulkDelete"
34export default class extends Controller {
35  static values = {
36    bulkDeleteUrl:  String,
37    confirmMessage: String,
38    selectLabel:    String,
39    deselectLabel:  String,
40    errorMessage:   String
41  }
42  static targets = ["selectAllButton", "deleteButton", "deleteCount"]
43
44  connect() {
45    this._syncSelectAllLabel()
46    this._syncDeleteButton()
47    this._schedulePendingPoll()
48  }
49
50  disconnect() {
51    clearTimeout(this._pollTimer)
52  }
53
54  runSearch() {
55    const field = document.getElementById("search_field")
56    const hidden = document.getElementById("search_term")
57    if (field && hidden) hidden.value = field.value
58    const form = document.getElementById("beverage_form")
59    if (form) form.submit()
60  }
61
62  onSearchKey(event) {
63    if (event.key === "Enter") {
64      event.preventDefault()
65      this.runSearch()
66    }
67  }
68
69  // #1610 — the select-all button toggles every row's delete switch on/off; its
70  // label flips between "Select all for delete" and "Deselect all".
71  toggleAll() {
72    const boxes = this._boxes()
73    const allChecked = boxes.length > 0 && boxes.every((b) => b.checked)
74    boxes.forEach((b) => { b.checked = !allChecked })
75    this._syncSelectAllLabel()
76    this._syncDeleteButton()
77  }
78
79  // A row switch changed → reflect the all-selected state on the button label
80  // and show/hide + count the floating delete button.
81  syncSelectAllLabel() {
82    this._syncSelectAllLabel()
83    this._syncDeleteButton()
84  }
85
86  // #1958 — the floating solid-red delete button is revealed only when ≥1 row is
87  // selected and carries a live "(N)" count, so a long inactive list always has
88  // a reachable way to finish a deletion.
89  _syncDeleteButton() {
90    if (!this.hasDeleteButtonTarget) return
91    const n = this._boxes().filter((b) => b.checked).length
92    this.deleteButtonTarget.hidden = n === 0
93    if (this.hasDeleteCountTarget) this.deleteCountTarget.textContent = n > 0 ? ` (${n})` : ""
94  }
95
96  // Pending rows (queued for deletion) render their switch disabled — leave
97  // them out of select-all, the count, and the next delete.
98  _boxes() {
99    return Array.from(this.element.querySelectorAll(".stock-delete-checkbox:not(:disabled)"))
100  }
101
102  _syncSelectAllLabel() {
103    if (!this.hasSelectAllButtonTarget) return
104    const boxes = this._boxes()
105    const allChecked = boxes.length > 0 && boxes.every((b) => b.checked)
106    this.selectAllButtonTarget.textContent = allChecked
107      ? (this.deselectLabelValue || "Deselect all")
108      : (this.selectLabelValue || "Select all for delete")
109  }
110
111  async bulkDelete(event) {
112    event.preventDefault()
113    const confirmMsg = this.confirmMessageValue || "Are you sure?"
114    if (!window.confirm(confirmMsg)) return
115
116    const checked = document.querySelectorAll(".stock-delete-checkbox:checked:not(:disabled)")
117    const ids = Array.from(checked).map((el) =>
117 el.value || el.dataset.stockRowId).filter(Boolean)
118    if (ids.length === 0) return
119
120    // Spin the clicked Delete button rather than dimming the whole container.
121    // The button lives in the section header (outside #beverage_table_container),
122    // so it survives the innerHTML swap and stopButtonSpinner runs on a live node.
123    const button = event.currentTarget
124    const container = document.getElementById("beverage_table_container")
125    startButtonSpinner(button)
126
127    const csrfToken = document.querySelector("meta[name=csrf-token]")?.getAttribute("content")
128    const fd = new FormData()
129    fd.append("ids", ids.join(","))
130    try {
131      const res = await fetch(this.bulkDeleteUrlValue, {
132        method: "POST",
133        body: fd,
134        credentials: "same-origin",
135        headers: {
136          Accept: "text/html",
137          "X-Requested-With": "XMLHttpRequest",
138          "X-CSRF-Token": csrfToken || ""
139        }
140      })
141      if (!res.ok) throw new Error(`bulk_delete ${res.status}`)
142      if (container) this._replaceTable(await res.text())
143    } catch (_e) {
144      // #2417 — this used to fail silently, leaving every row still checked
145      // with the button still up, as if nothing had happened.
146      window.alert(this.errorMessageValue || "Something went wrong deleting those beverages. Please try again.")
147      this._refreshTable()
148    } finally {
149      stopButtonSpinner(button)
150    }
151  }
152
153  // #2417 — the deletion runs in a background job; rows queued for it render
154  // with [data-stock-pending]. Re-fetch the table (same sort/search, ?table=1)
155  // until none are left, so they drop off as the job gets through them.
156  _schedulePendingPoll() {
157    clearTimeout(this._pollTimer)
158    if (!this.element.querySelector("[data-stock-pending]")) return
159    this._pollTimer = setTimeout(() => this._refreshTable(), 3000)
160  }
161
162  async _refreshTable() {
163    const url = new URL(window.location.href)
164    url.searchParams.set("table", "1")
165    try {
166      const res = await fetch(url, {
167        credentials: "same-origin",
168        headers: { Accept: "text/html", "X-Requested-With": "XMLHttpRequest" }
169      })
170      if (res.ok) this._replaceTable(await res.text())
171      else this._schedulePendingPoll()
172    } catch (_e) {
173      this._schedulePendingPoll()
174    }
175  }
176
177  // Swap in fresh table HTML, keeping any rows the user has checked meanwhile.
178  _replaceTable(html) {
179    const container = document.getElementById("beverage_table_container")
180    if (!container) return
181    const checkedIds = new Set(this._boxes().filter((b) => b.checked).map((b) => b.value))
182    container.innerHTML = html
183    this._boxes().forEach((b) => { if (checkedIds.has(b.value)) b.checked = true })
184    this._syncSelectAllLabel()
185    this._syncDeleteButton()
186    this._schedulePendingPoll()
187  }
188};

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.