PageSourceSearch

https://lingfieldparishchurch.org/js/stores/modals.js?v=5

js lingfieldparishchurch.org collected 2026-09-25 00:58:22 UTC 14,850 bytes, 295 lines download raw bytes

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.