1import { Controller } from "@hotwired/stimulus" 2 3// Selection + cross-type bulk actions for the unified admin Content Library. 4// Each row checkbox carries a `type:id` token so a single selection can span 5// multiple content types; the server groups tokens by type before applying. 6// 7// Two selection modes (mirrors the ai-requests bulk-select controller): 8// - page: the explicitly ticked rows (items: [type:id, ...]). 9// - filtered: every item matching the current filters, across all pages. 10// Entered via "Select all N matching this filter" once the whole 11// page is ticked; bulk requests then send select_all_filtered + 12// the filter params so the server re-derives the scope. Sets 13// larger than the server's cap require a chunk confirmation. 14export default class extends Controller { 15 static targets = [ 16 "selection", 17 "selectAll", 18 "selectionCount", 19 "selectFilteredButton", 20 "clearFilteredButton", 21 "editButton", 22 "deleteButton", 23 "editModal", 24 "deleteModal", 25 "visibility", 26 "accessAction", 27 "accessTags", 28 "deleteCount", 29 "deleteConfirm", 30 "deleteConfirmButton", 31 "status", 32 "error", 33 "deleteStatus", 34 "deleteError" 35 ] 36 37 static values = { 38 updateUrl: String, 39 deleteUrl: String, 40 confirmPhrase: { type: String, default: "DELETE" }, 41 totalCount: { type: Number, default: 0 }, 42 filterParams: { type: Object, default: {} } 43 } 44 45 connect() { 46 this.allFiltered = false 47 this.updateSelectionCount() 48 } 49 50 toggleSelection() { 51 this.allFiltered = false 52 this.syncSelectAllState() 53 this.updateSelectionCount() 54 } 55 56 toggleAll(event) { 57 const checked = event.target.checked 58 if (!checked) this.allFiltered = false 59 this.selectionTargets.forEach((checkbox) => { checkbox.checked = checked }) 60 this.updateSelectionCount() 61 } 62 63 // "Select all N matching this filter" â act on the whole filtered scope. 64 selectAllFiltered() { 65 this.allFiltered = true 66 this.updateSelectionCount() 67 } 68 69 clearFilteredSelection() { 70 this.allFiltered = false 71 this.selectionTargets.forEach((checkbox) => { checkbox.checked = false }) 72 this.syncSelectAllState() 73 this.updateSelectionCount() 74 } 75 76 syncSelectAllState() { 77 if (!this.hasSelectAllTarget) return 78 const total = this.selectionTargets.length 79 const selected = this.selectedTokens().length 80 this.selectAllTarget.checked = total > 0 && selected === total 81 this.selectAllTarget.indeterminate = selected > 0 && selected < total 82 } 83 84 // Number of records a bulk action would actually touch. 85 effectiveCount() { 86 return this.allFiltered ? this.totalCountValue : this.selectedTokens().length 87 } 88 89 updateSelectionCount() { 90 const count = this.selectedTokens().length 91 const effective = this.effectiveCount() 92 93 if (this.hasSelectionCountTarget) { 94 this.selectionCountTarget.textContent = this.allFiltered 95 ? `All ${this.totalCountValue} matching this filter selected` 96 : `${count} selected` 97 } 98 if (this.hasEditButtonTarget) this.editButtonTarget.disabled = effective === 0 99 if (this.hasDeleteButtonTarget) this.deleteButtonTarget.disabled = effective === 0 100 101 // Offer the cross-page expand only when the whole page is ticked and more 102 // rows match the filter beyond it. 103 if (this.hasSelectFilteredButtonTarget) { 104 const offerExpand = !this.allFiltered && count > 0 && 105 count === this.selectionTargets.length && this.totalCountValue > count 106 this.selectFilteredButtonTarget.textContent = `Select all ${this.totalCountValue} matching this filter` 107 this.selectFilteredButtonTarget.classList.toggle("hidden", !offerExpand) 108 } 109 if (this.hasClearFilteredButtonTarget) { 110 this.clearFilteredButtonTarget.classList.toggle("hidden", !this.allFiltered) 111 } 112 } 113 114 selectedTokens() { 115 return this.selectionTargets 116 .filter((checkbox) => checkbox.checked) 117 .map((checkbox) => checkbox.dataset.contentLibraryItemToken) 118 .filter((token) => token) 119 } 120 121 // The selection portion of a bulk request body. 122 selectionParams() { 123 if (this.allFiltered) { 124 return { select_all_filtered: true, filters: this.filterParamsValue } 125 } 126 return { items: this.selectedTokens() } 127 } 128 129 // ââ Edit (visibility + access tags) modal âââââââââââââââââââââââââââ 130 131 openEditModal() { 132 if (this.effectiveCount() === 0) return 133 this.clearMessages() 134 this.editModalTarget.classList.remove("hidden") 135 this.editModalTarget.setAttribute("aria-hidden", "false") 136 } 137 138 closeEditModal() { 139 this.editModalTarget.classList.add("hidden") 140 this.editModalTarget.setAttribute("aria-hidden", "true") 141 } 142 143 async applyChanges() { 144 this.clearMessages() 145 if (this.effectiveCount() === 0) { 146 this.showError("Select at least one item.") 147 return 148 } 149 150 const visibility = this.hasVisibilityTarget ? this.visibilityTarget.value : "" 151 const accessAction = this.hasAccessActionTarget ? this.accessActionTarget.value : "" 152 const accessTags = this.parsedTags() 153 154 if (!visibility && !accessAction) { 155 this.showError("Choose a visibility or access tag update.") 156 return 157 } 158 if (["set", "add", "remove"].includes(accessAction) && accessTags.length === 0) { 159 this.showError("Enter at least one access tag for that action.") 160 return 161 } 162 163 const body = this.selectionParams() 164 if (visibility) body.visibility = visibility 165 if (accessAction) { 166 body.access_tag_action = accessAction 167 body.access_tags = accessTags 168 } 169 170 this.setStatus("Applyingâ¦") 171 try { 172 const payload = await this.submitBulk(this.updateUrlValue, "PATCH", body) 173 if (!payload) { 174 this.setStatus("") 175 return 176 } 177 178 const parts = [] 179 if (payload.visibility_updated != null) parts.push(`${payload.visibility_updated} visibility`) 180 if (payload.tags_updated != null) parts.push(`${payload.tags_updated} tag`) 181 const remaining = payload.remaining > 0 ? ` ${payload.remaining} matching items remain â rerun for the rest.` : "" 182 this.setStatus(`Updated ${parts.join(", ")}.${remaining} Reloadingâ¦`) 183 window.location.reload() 184 } catch (error) { 185 this.showError(error.message || "Unable to apply changes.") 186 } 187 } 188 189 parsedTags() { 190 if (!this.hasAccessTagsTarget) return [] 191 return this.accessTagsTarget.value 192 .split(/[\n,]/) 193 .map((tag) => tag.trim()) 194 .filter((tag) => tag) 195 } 196 197 // ââ Delete modal ââââââââââââââââââââââââââââââââââââââââââââââââââââ 198 199 openDeleteModal() { 200 const count = this.effectiveCount() 201 if (count === 0) return 202 this.clearDeleteMessages() 203 if (this.hasDeleteCountTarget) { 204 this.deleteCountTarget.textContent = `${count} ${count === 1 ? "item" : "items"}` 205 } 206 if (this.hasDeleteConfirmTarget) this.deleteConfirmTarget.value = "" 207 this.refreshDeleteButton() 208 this.deleteModalTarget.classList.remove("hidden") 209 this.deleteModalTarget.setAttribute("aria-hidden", "false") 210 } 211 212 closeDeleteModal() { 213 this.deleteModalTarget.classList.add("hidden") 214 this.deleteModalTarget.setAttribute("aria-hidden", "true") 215 } 216 217 refreshDeleteButton() { 218 if (!this.hasDeleteConfirmButtonTarget) return 219 const value = this.hasDeleteConfirmTarget ? this.deleteConfirmTarget.value.trim() : "" 220 this.deleteConfirmButtonTarget.disabled = value !== this.confirmPhraseValue 221 } 222 223 async confirmDelete() { 224 this.clearDeleteMessages() 225 if (this.effectiveCount() === 0) { 226 this.showDeleteError("Select at least one item.") 227 return 228 } 229 if (this.hasDeleteConfirmTarget && this.deleteConfirmTarget.value.trim() !== this.confirmPhraseValue) { 230 this.showDeleteError(`Type ${this.confirmPhraseValue} to confirm.`) 231 return 232 } 233 234 if (this.hasDeleteConfirmButtonTarget) this.deleteConfirmButtonTarget.disabled = true 235 this.setDeleteStatus("Deletingâ¦") 236 try { 237 const payload = await this.submitBulk(this.deleteUrlValue, "DELETE", this.selectionParams()) 238 if (!payload) { 239 this.setDeleteStatus("") 240 this.refreshDeleteButton() 241 return 242 } 243 244 const deleted = payload.deleted ?? 0 245 const remaining = payload.remaining > 0 ? ` ${payload.remaining} matching items remain â rerun for the rest.` : "" 246 this.setDeleteStatus(`Deleted ${deleted}.${remaining} Reloadingâ¦`) 247 window.location.reload() 248 } catch (error) { 249 this.showDeleteError(error.message || "Unable to delete the selected items.") 250 this.refreshDeleteButton() 251 } 252 } 253 254 // POSTs a bulk request. When a whole-filter selection exceeds the server's 255 // per-run cap, the server answers 422 + requires_chunk_confirmation; ask the 256 // admin, then retry once with confirm_chunk so the oldest chunk is 257 // processed. Returns the response payload, or null when the admin declines 258 // the chunk confirmation. 259 async submitBulk(url, method, body) { 260 let response = await fetch(url, { 261 method, 262 headers: this.requestHeaders(), 263 body: JSON.stringify(body) 264 }) 265 let payload = await response.json().catch(() => ({})) 266 267 if (!response.ok && payload.requires_chunk_confirmation) { 268 const proceed = window.confirm( 269 `${payload.matching} items match this filter â more than the ${payload.cap}-item cap for one run. ` + 270 `Process the oldest ${payload.cap} now? Rerun the action afterwards for the rest.` 271 ) 272 if (!proceed) return null 273 274 response = await fetch(url, { 275 method, 276 headers: this.requestHeaders(), 277 body: JSON.stringify({ ...body, confirm_chunk: true }) 278 }) 279 payload = await response.json().catch(() => ({})) 280 } 281 282 if (!response.ok) throw new Error(payload.error || "Request failed.") 283 return payload 284 } 285 286 // ââ Helpers âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 287 288 stopPropagation(event) { 289 event.stopPropagation() 290 } 291 292 setStatus(message) { this.toggleMessage(this.statusTarget, this.hasStatusTarget, message) } 293 showError(message) { this.toggleMessage(this.errorTarget, this.hasErrorTarget, message) } 294 setDeleteStatus(message) { this.toggleMessage(this.deleteStatusTarget, this.hasDeleteStatusTarget, message) } 295 showDeleteError(message) { this.toggleMessage(this.deleteErrorTarget, this.hasDeleteErrorTarget, message) } 296 297 toggleMessage(target, present, message) { 298 if (!present) return 299 target.textContent = message 300 target.classList.toggle("hidden", !message) 301 } 302 303 clearMessages() { 304 this.setStatus("") 305 this.showError("") 306 } 307 308 clearDeleteMessages() { 309 this.setDeleteStatus("") 310 this.showDeleteError("") 311 } 312 313 requestHeaders() { 314 const token = document.querySelector('meta[name="csrf-token"]')?.content 315 return { 316 "Content-Type": "application/json", 317 Accept: "application/json", 318 "X-CSRF-Token": token 319 } 320 } 321}
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.