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.