1window.appStores = window.appStores || {}; 2 3window.appStores.modals = { 4 stack: [], 5 6 // A handy getter to safely access the topmost modal 7 get top() { 8 return this.stack.length > 0 ? this.stack[this.stack.length - 1] : null; 9 }, 10 11 open(modalData) { 12 // Elevation: if an "-above-modal" slideout (e.g. the media picker, 13 // panel z 2100) is currently open, this modal was triggered from 14 // inside it (delete confirm, bulk-delete confirm, discard-changes) and 15 // must stack ABOVE the slideout, not behind it. Stamp a higher z-base 16 // on just this modal. Modals opened with no elevated slideout keep the 17 // default 2000 base, so a picker launched afterwards (from a TinyMCE 18 // editor inside a modal) can still sit above them â the two cases don't 19 // fight because the base is captured per-modal at open time. 20 const slideout = (typeof Alpine !== 'undefined' && Alpine.store) 21 ? Alpine.store('slideout') 22 : (window.appStores && window.appStores.slideout); 23 const elevatedSlideoutOpen = !!(slideout && slideout.isOpen 24 && typeof slideout.panelClass === 'string' 25 && slideout.panelClass.indexOf('-above-modal') !== -1); 26 const zBase = elevatedSlideoutOpen ? 2200 : 2000; 27 28 // Every new modal automatically gets a reactive 'errors' object. 29 const newModal = { ...modalData, errors: {}, isFatal: false, zBase }; 30 this.stack.push(newModal); 31 }, 32 33 close() { 34 // flatpickr mounts its calendar on <body>, outside the dialog body Alpine is about to 35 // remove, so any picker whose input leaves the document with this close is destroyed here. 36 const root = document.getElementById('alpine-modal-root'); 37 const pickers = root 38 ? Array.from(root.querySelectorAll('.date-picker-initialized')).map((el) => el._flatpickr).filter(Boolean) 39 : []; 40 this.stack.pop(); 41 if (pickers.length && typeof Alpine !== 'undefined' && Alpine.nextTick) { 42 Alpine.nextTick(() => pickers.forEach((fp) => { 43 if (fp.input && !document.contains(fp.input)) { 44 try { fp.destroy(); } catch (e) { /* already gone */ } 45 } 46 })); 47 } 48 }, 49 50 isOpen() { 51 return this.stack.length > 0; 52 }, 53 54 // Mutate the errors object by deleting keys (keeps the reactive connection 55 // intact for anything bound to the store) and re-render the modal. 56 clearTopModalErrors() { 57 if (this.top) { 58 for (const key in this.top.errors) { 59 delete this.top.errors[key]; 60 } 61 this.top.isFatal = false; 62 } 63 this._renderErrors(); 64 }, 65 66 /** 67 * Wire client-side required-field validation into a form modal once its 68 * body fragment has been htmx-swapped in. Mirrors slideoutForm.js: fields 69 * carrying .required (etc.) get the same fieldValidator treatment â red 70 * "is required" cue + tippy message â and the submit is gated client-side 71 * before the htmx post (server validation stays authoritative). bind() is 72 * idempotent (self-guards on _fvBound), so repeat swaps are harmless. 73 * @param {CustomEvent} event - the htmx:afterSwap event (target = swapped body). 74 */ 75 bindFormValidation(event) { 76 if (!window.fieldValidator) return; 77 const swapped = event?.target; 78 const form = swapped?.closest?.('form'); 79 if (!form) return; 80 const root = document.getElementById('alpine-modal-root'); 81 if (root && root.contains(form)) window.fieldValidator.bind(form); 82 }, 83 84 /** 85 * NEW: A centralized handler for all HTMX responses. 86 * This keeps the logic out of the HTML and in one clean place. 87 * @param {CustomEvent} event - The htmx:after-request event. 88 */ 89 handleHtmxResponse(event) { 90 const xhr = event.detail.xhr; 91 92 // A failed GET issued by the dialog's own .dialog-body is the form failing to 93 // load, not a submission failing validation: there is nothing for the operator
94 // to correct and nothing left to submit, so the dialog can only report and close. 95 const elt = event.detail.elt; 96 const is_body_load = event.detail.requestConfig?.verb === 'get' 97 && !!(elt && typeof elt.closest === 'function' && elt.closest('.dialog-body')); 98 99 if (event.detail.failed || xhr.status === 422) { 100 event.preventDefault(); // Stop HTMX from showing its own alert 101 try { 102 const errorData = JSON.parse(xhr.responseText); 103 if (this.top) { // Use 'this.top' instead of '$store.modals.top' 104 if (errorData.meta && errorData.errors) { 105 Object.assign(this.top.errors, errorData.errors); // Use Object.assign for reactivity 106 this.top.isFatal = errorData.meta.isFatal || is_body_load; 107 } else { 108 Object.assign(this.top.errors, errorData.errors || errorData); 109 this.top.isFatal = is_body_load; 110 } 111 this._renderErrors(); 112 } else { 113 // No modal open (e.g. drag-drop / inline action): errorData is 114 // `{errors: {general: '...'}}` or `{errors: {...field msgs}}`. 115 // Flatten to a string so the toast doesn't render "[object Object]". 116 let msg = errorData.errors ?? errorData.message ?? errorData; 117 if (msg && typeof msg === 'object') msg = Object.values(msg).join(' '); 118 notify("Action Failed", String(msg || 'Something went wrong'), "error"); 119 } 120 } catch (e) { 121 // Non-JSON failure body. A loader answering an expected state ("no items 122 // eligible") dies with a bare sentence under a 500, so a short plain-text 123 // body is the operator's message and stands alone; only an empty or HTML 124 // body (a real error page) needs the status code to say anything at all. 125 if (this.top) { 126 const body = (xhr.responseText || '').trim(); 127 const is_message = body.length > 0 && body.length < 300 && body.indexOf('<') === -1; 128 if (!is_message) console.error('Failed to parse validation errors:', e); 129 this.top.errors.general = is_message ? body : 'Request failed (HTTP ' + xhr.status + ')'; 130 this.top.isFatal = true; 131 this._renderErrors(); 132 } 133 } 134 } 135 // --- 200: Success (with potential next actions) --- 136 else if (xhr.status === 200) { 137 try { 138 const data = JSON.parse(xhr.responseText); 139 if (data.taskId) { 140 this.close(); 141 this.showProgressBar(data.taskId, data.stoppable); 142 } else if (data.downloadUrl) { // file download (e.g. bulk CSV export) 143 this.close(); 144 this.triggerDownload(data.downloadUrl); 145 } else if (data.redirectUrl) { // Note: 'redirectUrl' implies 'successAndRedirect' 146 this.close(); 147 this.showSuccessAndRedirect(data.message, data.redirectUrl); 148 } else if (data.reload) { 149 this.close(); 150 // Redraw the list the action just changed. Three routes, most 151 // faithful first: 152 // 153 // 1. #results-redraw â an anchor the list page renders inside 154 // #search-results-content carrying the server's OWN current 155 // query ({this_url} = path + Query::getURL(), i.e. every 156 // registered filter plus sort/sort_dsc/pg/show). Re-running 157 // that is exactly the search the server just performed, so 158 // filters, sort order and page position all survive and the 159 // chips are left untouched. Prefer it whenever the page 160 // offers it. 161 // 2. searchTags._submit() â rebuilds the query from the chip 162 // store. Lossy (chips can't represent a filter the store 163 // never managed to rebuild), so it is only the fallback for 164 // pages with no redraw anchor. 165 // 3. location.reload() â no active search at all; an empty tag 166 // list would otherwise post a tagless search against 167 // #search-results-content (which on views like the forum 168 // thread wraps the entire post list). 169 const redraw = document.getElementById('results-redraw'); 170 const searchTags = Alpine?.store?.('searchTags'); 171 const hasActiveTags = searchTags && Array.isArray(searchTags.tags) && searchTags.tags.length > 0; 172 if (redraw && typeof htmx !== 'undefined') { 173 // Keep the selection. This redraw re-runs the SAME search, 174 // and the server re-renders the same row as selected â the 175 // selected order lives in the session (URL::setSessionVar), 176 // and the row-actions bar and detail panes are rendered 177 // from it. Clearing the store here left the two halves 178 // disagreeing: the highlight and x-show bindings went off 179 // while the server-rendered actions stayed on screen. 180 // Only drop the selection if the action took the row out 181 // of the result set (voided, cancelled, filtered away). 182 htmx.ajax('GET', redraw.getAttribute('hx-get') || redraw.href, { 183 target: redraw.getAttribute('hx-target') || '#search-results-content', 184 select: redraw.getAttribute('hx-select') || '#search-results-content', 185 swap: redraw.getAttribute('hx-swap') || 'innerHTML' 186 }).then(() => { 187 const selection = Alpine?.store?.('selection'); 188 const id = selection?.selectedId; 189 if (!id) return; 190 const stillListed = document.querySelector( 191 `#search-results-content tr[data-selected-id="${CSS.escape(String(id))}"]` 192 ); 193 if (!stillListed) { 194 selection.clear(); 195 return; 196 } 197 // The selected row's detail panes (allocation, stock, 198 // notesâ¦) sit OUTSIDE the swapped region and refresh on 199 // selectionChanged. The selection didn't change, but the 200 // row's data just did â re-fire so they don't keep 201 // showing the pre-action state. 202 htmx.trigger(document.body, 'selectionChanged'); 203 }); 204 } else if (hasActiveTags && document.getElementById('advanced-search-hidden-form')) { 205 searchTags._submit(); 206 } else { 207 location.reload(); 208 } 209 } 210 // We no longer need to check for other actions here, as the progress bar
211 // now handles the final step (flashAndRedirect, triggerEvents, etc.) 212 } catch (e) { 213 // Not a JSON response we need to act on, so we ignore it. 214 // This could be a successful pane refresh, for example. 215 } 216 } 217 }, 218 219 /** 220 * Render the topmost modal's errors directly into the DOM. For each error 221 * key, finds the form field with that `name` attribute and appends a 222 * `<div class="validation-error">` to its containing `.form-group` / 223 * `.input-container`. The `general` key targets the static 224 * `<div data-error-general>` slot. 225 * 226 * Always wipes previously-rendered errors before re-rendering so callers 227 * can simply mutate the store and call this method. 228 */ 229 _renderErrors() { 230 const root = document.getElementById('alpine-modal-root'); 231 if (!root) return; 232 233 // Wipe previously-rendered field errors and reset the general slot. 234 root.querySelectorAll('[data-auto-error]').forEach((el) => el.remove()); 235 const generalSlot = root.querySelector('[data-error-general]'); 236 if (generalSlot) { 237 generalSlot.innerHTML = ''; 238 generalSlot.style.display = 'none'; 239 } 240 241 if (!this.top || !this.top.errors) return; 242 243 Object.entries(this.top.errors).forEach(([key, msg]) => { 244 if (key === 'general') { 245 if (generalSlot) { 246 generalSlot.innerHTML = DOMPurify.sanitize(msg); 247 generalSlot.style.display = 'block'; 248 } 249 return; 250 } 251 252 const field = root.querySelector(`[name="${CSS.escape(key)}"]`); 253 if (!field) return; 254 255 const container = field.closest('.form-group, .input-container') || field.parentElement; 256 if (!container) return; 257 258 const div = document.createElement('div'); 259 div.className = 'validation-error'; 260 div.setAttribute('data-auto-error', key); 261 div.innerHTML = DOMPurify.sanitize(msg); 262 container.appendChild(div); 263 }); 264 }, 265 266 // Kick off a file download without navigating the page. The server drives 267 // the filename via Content-Disposition, so no `download` attribute is set. 268 triggerDownload(url) { 269 const a = document.createElement('a'); 270 a.href = url; 271 a.rel = 'noopener'; 272 document.body.appendChild(a); 273 a.click(); 274 a.remove(); 275 }, 276 277 showProgressBar(taskId, stoppable = false) { 278 // A job was just dispatched â nudge the activity tray to pop now 279 // instead of waiting for its next poll. 280 if (window.kcActivityPoke) window.kcActivityPoke(); 281 this.open({ 282 type: 'progress', 283 taskId: taskId, 284 stoppable: stoppable 285 }); 286 }, 287 288 showSuccessAndRedirect(message, redirectUrl) { 289 this.open({ 290 type: 'success', 291 message: message, 292 redirectUrl: redirectUrl 293 }); 294 } 295};
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.