Utilizzando SweetAlert2 per indicatori di carico HTMX (indicatore hx) (Italiano (Italian))

Utilizzando SweetAlert2 per indicatori di carico HTMX (indicatore hx)

Comments

NOTE: Apart from English (and even then it's questionable, I'm Scottish). These are machine translated in languages I don't read. If they're terrible please contact me.
You can see how this translation was done in this article.

Monday, 21 April 2025

//

Less than a minute

Introduzione

Su un progetto di lavoro che sto usando e abusando di HTMX per costruire un'interfaccia utente admin. Come parte di questo sto usando il delizioso SweetAlert2City name (optional, probably does not need a translation) Libreria Javascript per le mie finestre di conferma. Funziona alla grande, ma volevo anche usarli per sostituire i miei indicatori di carico HTMX.

Questa si e' rivelata una SFIDE, cosi' ho pensato di documentarla qui per salvarti lo stesso dolore.

Warning I'm a C# coder my Javascript is likely horrible.

[TOC]

Il problema

Quindi HTMX è molto intelligente, è hx-indicator normalmente consente di impostare un indicatore di carico per le vostre richieste HTMX. Normalmente questo è un elemento HTML nella tua pagina come


<div id="loading-modal" class="modal htmx-indicator">
    <div class="modal-box flex flex-col items-center justify-center bg-base-200 border border-base-300 shadow-xl rounded-xl text-base-content dark text-center ">
        <div class="flex flex-col items-center space-y-4">
            <h2 class="text-lg font-semibold tracking-wide">Loading...</h2>
            <span class="loading loading-dots loading-xl text-4xl text-stone-200"></span>
        </div>
    </div>
</div>

Poi quando si desidera utilizzarlo si 'd decorare la vostra richiesta HTMX con hx-indicator="#loading-modal" e mostrerà il modo in cui la richiesta è in corso (vedi qui per maggiori dettagli).

Ora HTMX fa qualche magia intelligente utilizzando un request oggetto Rintraccia internamente

  function addRequestIndicatorClasses(elt) {
    let indicators = /** @type Element[] */ (findAttributeTargets(elt, 'hx-indicator'))
    if (indicators == null) {
      indicators = [elt]
    }
    forEach(indicators, function(ic) {
      const internalData = getInternalData(ic)
      internalData.requestCount = (internalData.requestCount || 0) + 1
      ic.classList.add.call(ic.classList, htmx.config.requestClass)
    })
    return indicators
  }

La loro sostituzione è quindi un po' una sfida. Come rintracciare le richieste e poi mostrare il modo SweetAlert2 quando la richiesta è in corso e nasconderlo quando è finito.

Una soluzione

Così ho impostato circa (non perché ho dovuto, perché ho bisogno di :)) per sostituire l'indicatore di carico HTMX con un modo SweetAlert2. Comunque, ecco il codice che ho trovato.

Inizieresti importando SweetAlert2 nel tuo HTML (come script & style tag) / importandolo per il webpack o simili (vedere i loro documenti per questo).

Dopo l'installazione di npm è possibile importare in questo modo nel file JS.

import Swal from 'sweetalert2';

Allora il mio codice principale assomiglia a questo:

import Swal from 'sweetalert2';

const SWEETALERT_PATH_KEY = 'swal-active-path'; // Stores the path of the current SweetAlert
const SWEETALERT_HISTORY_RESTORED_KEY = 'swal-just-restored'; // Flag for navigation from browser history
const SWEETALERT_TIMEOUT_MS = 10000; // Timeout for automatically closing the loader

let swalTimeoutHandle = null;

export function registerSweetAlertHxIndicator() {
    document.body.addEventListener('htmx:configRequest', function (evt) {
        const trigger = evt.detail.elt;
        const indicatorAttrSource = getIndicatorSource(trigger);
        if (!indicatorAttrSource) return;

        const path = getRequestPath(evt.detail);
        if (!path) return;

        const currentPath = sessionStorage.getItem(SWEETALERT_PATH_KEY);

        // Show SweetAlert only if the current request path differs from the previous one
        if (currentPath !== path) {
            closeSweetAlertLoader();
            sessionStorage.setItem(SWEETALERT_PATH_KEY, path);
            evt.detail.indicator = null; // Disable HTMX's default indicator behavior

            Swal.fire({
                title: 'Loading...',
                allowOutsideClick: false,
                allowEscapeKey: false,
                showConfirmButton: false,
                theme: 'dark',
                didOpen: () => {
                    // Cancel immediately if restored from browser history
                    if (sessionStorage.getItem(SWEETALERT_HISTORY_RESTORED_KEY) === 'true') {
                        sessionStorage.removeItem(SWEETALERT_HISTORY_RESTORED_KEY);
                        Swal.close();
                        return;
                    }

                    Swal.showLoading();
                    document.dispatchEvent(new CustomEvent('sweetalert:opened'));

                    // Set timeout to auto-close if something hangs
                    clearTimeout(swalTimeoutHandle);
                    swalTimeoutHandle = setTimeout(() => {
                        if (Swal.isVisible()) {
                            console.warn('SweetAlert loading modal closed after timeout.');
                            closeSweetAlertLoader();
                        }
                    }, SWEETALERT_TIMEOUT_MS);
                },
                didClose: () => {
                    document.dispatchEvent(new CustomEvent('sweetalert:closed'));
                    sessionStorage.removeItem(SWEETALERT_PATH_KEY);
                    clearTimeout(swalTimeoutHandle);
                    swalTimeoutHandle = null;
                }
            });
        } else {
            // Suppress HTMX indicator if the path is already being handled
            evt.detail.indicator = null;
        }
    });

    //Add events to close the loader
    document.body.addEventListener('htmx:afterRequest', maybeClose);
    document.body.addEventListener('htmx:responseError', maybeClose);
    document.body.addEventListener('sweetalert:close', closeSweetAlertLoader);

    // Set a flag so we can suppress the loader immediately if navigating via browser history
    document.body.addEventListener('htmx:historyRestore', () => {
        sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true');
    });

    window.addEventListener('popstate', () => {
        sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true');
    });
}

// Returns the closest element with an indicator attribute
function getIndicatorSource(el) {
    return el.closest('[hx-indicator], [data-hx-indicator]');
}

// Determines the request path, including query string if appropriate
function getRequestPath(detail) {
    const responsePath =
        typeof detail?.pathInfo?.responsePath === 'string'
            ? detail.pathInfo.responsePath
            : (typeof detail?.pathInfo?.path === 'string'
                    ? detail.pathInfo.path
                    : (typeof detail?.path === 'string' ? detail.path : '')
            );

    const elt = detail.elt;

    // If not a form and has an hx-indicator, use the raw path
    if (elt.hasAttribute("hx-indicator") && elt.tagName !== "FORM") {
        return responsePath;
    }

    const isGet = (detail.verb ?? '').toUpperCase() === 'GET';
    const form = elt.closest('form');

    // Append query params for GET form submissions
    if (isGet && form) {
        const params = new URLSearchParams();

        for (const el of form.elements) {
            if (!el.name || el.disabled) continue;

            const type = el.type;
            if ((type === 'checkbox' || type === 'radio') && !el.checked) continue;
            if (type === 'submit') continue;

            params.append(el.name, el.value);
        }

        const queryString = params.toString();
        return queryString ? `${responsePath}?${queryString}` : responsePath;
    }

    return responsePath;
}

// Closes the SweetAlert loader if the path matches
function maybeClose(evt) {
    const activePath = sessionStorage.getItem(SWEETALERT_PATH_KEY);
    const path = getRequestPath(evt.detail);

    if (activePath && path && activePath === path) {
        closeSweetAlertLoader();
    }
}

// Close and clean up SweetAlert loader state
function closeSweetAlertLoader() {
    if (Swal.getPopup()) {
        Swal.close();
        document.dispatchEvent(new CustomEvent('sweetalert:closed'));
        sessionStorage.removeItem(SWEETALERT_PATH_KEY);
        clearTimeout(swalTimeoutHandle);
        swalTimeoutHandle = null;
    }
}

Configura questo (se stai usando il MES) nel tuo main.js file come questo


import { registerSweetAlertHxIndicator } from './hx-sweetalert-indicator.js';
registerSweetAlertHxIndicator();

Trovare i nostri elementi

Vedrete che uso il getIndicatorSource funzione per trovare l'elemento che ha attivato la richiesta HTMX. Questo è importante perché dobbiamo sapere quale elemento ha innescato la richiesta in modo da poter chiudere il modo quando è finito. Questo è importante perché HTMX ha 'ereditarietà' quindi è necessario scalare l'albero per trovare l'elemento che ha innescato la richiesta.

function getIndicatorSource(el) {
    return el.closest('[hx-indicator], [data-hx-indicator]');
}

Quindi su qualsiasi richiesta HTMX (così hx-get oppure hx-post) è possibile utilizzare il hx-indicator attributo per specificare la modalità SweetAlert2. Non è nemmeno necessario specificare la classe come prima, solo il parametro esistente funziona.

Vediamo come funziona tutto questo:

Sto cercando di tirarlo su con... registerSweetAlertHxIndicator()

Questo funge da punto d'ingresso. Si può vedere che si aggancia nel htmx:configRequest evento. Questo viene licenziato quando HTMX sta per fare una richiesta.

Poi ottiene l'elemento che ha innescato l'evento in evt.detail.elt e controlla se ha un hx-indicator attributo.

Infine, mostra la modalità SweetAlert2 utilizzando Swal.fire().

rt function registerSweetAlertHxIndicator() {
    document.body.addEventListener('htmx:configRequest', function (evt) {
        const trigger = evt.detail.elt;
        const indicatorAttrSource = getIndicatorSource(trigger);
        if (!indicatorAttrSource) return;
        

Ottenere il percorso della richiesta

Se lo fa, ottiene il percorso della richiesta usando getRequestPath(evt.detail) e lo memorizza in un deposito di sessione. Niw HTMX è un bugger difficile, memorizza il percorso in luoghi diversi a seconda di dove si è nel ciclo di vita. Quindi nel mio codice faccio TUTTA LA SENZA. con detail?.pathInfo?.path ?? detail?.path ?? '';

Si scopre che HTMX ha memorizzato il richiesta percorso dentro detail.path e il percorso di risposta (per document.body.addEventListener('htmx:afterRequest', maybeClose); document.body.addEventListener('htmx:responseError', maybeClose);) in detail.PathInfo.responsePath Quindi dobbiamo gestire entrambi.

Abbiamo anche bisogno di gestire GET forme; poiché la loro risposta probabilmente includerà gli elementi URL passati come <input > valori in modo che l'url di risposta può finire per essere diverso.

// Returns the closest element with an indicator attribute
function getIndicatorSource(el) {
    return el.closest('[hx-indicator], [data-hx-indicator]');
}

// Determines the request path, including query string if appropriate
function getRequestPath(detail) {
    const responsePath =
        typeof detail?.pathInfo?.responsePath === 'string'
            ? detail.pathInfo.responsePath
            : (typeof detail?.pathInfo?.path === 'string'
                    ? detail.pathInfo.path
                    : (typeof detail?.path === 'string' ? detail.path : '')
            );

    const elt = detail.elt;

    // If not a form and has an hx-indicator, use the raw path
    if (elt.hasAttribute("hx-indicator") && elt.tagName !== "FORM") {
        return responsePath;
    }

    const isGet = (detail.verb ?? '').toUpperCase() === 'GET';
    const form = elt.closest('form');

    // Append query params for GET form submissions
    if (isGet && form) {
        const params = new URLSearchParams();

        for (const el of form.elements) {
            if (!el.name || el.disabled) continue;

            const type = el.type;
            if ((type === 'checkbox' || type === 'radio') && !el.checked) continue;
            if (type === 'submit') continue;

            params.append(el.name, el.value);
        }

        const queryString = params.toString();
        return queryString ? `${responsePath}?${queryString}` : responsePath;
    }

    return responsePath;
}

NOTA: Questo è particolarmente il caso se si utilizza il HX-Push-Url header per modificare l'URL della richiesta che HTMX memorizza per History.

Il modulo

HttpGet form sono un po 'difficile quindi abbiamo un pezzo di codice che rileva se hai cliccato un submit pulsante all'interno di un modulo e aggiungere i parametri della stringa di query causati da quegli ingressi fastidiosi da confrontare con l'URL di risposta.

const isGet = (detail.verb ?? '').toUpperCase() === 'GET';
    const form = elt.closest('form');

    // Append query params for GET form submissions
    if (isGet && form) {
        const params = new URLSearchParams();

        for (const el of form.elements) {
            if (!el.name || el.disabled) continue;

            const type = el.type;
            if ((type === 'checkbox' || type === 'radio') && !el.checked) continue;
            if (type === 'submit') continue;

            params.append(el.name, el.value);
        }

        const queryString = params.toString();
        return queryString ? `${responsePath}?${queryString}` : responsePath;
    }

    return responsePath;
    ```
    
This is important as HTMX will use the response URL to determine if the request is the same as the previous one. So we need to ensure we have the same URL in both places.

### Extensions
I use this little `Response` extension method to set the `HX-Push-Url` header in my ASP.NET Core app. I also added a second extension which will immediately close the modal (useful if you mess with the request and need to close it immediately). 
```csharp
public static class ResponseExtensions
{
    public static void PushUrl(this HttpResponse response, HttpRequest request)
    {
        response.Headers["HX-Push-Url"] = request.GetEncodedUrl();
    }
}
    public static void CloseSweetAlert(this HttpResponse response)
    {
        response.Headers.Append("HX-Trigger" , JsonSerializer.Serialize(new
        {
            sweetalert = "closed"
        }));

    }
}

Questo secondo è gestito qui:

    document.body.addEventListener('sweetalert:close', closeSweetAlertLoader);

Archiviare il percorso

Ok, quindi ora abbiamo il sentiero, cosa ne facciamo? Bene per tenere traccia di quale richiesta ha attivato il modal SweetAlert2 lo memorizziamo in sessionStorage utilizzo sessionStorage.setItem(SWEETALERT_PATH_KEY, path);.

(Di nuovo è possibile rendere questo più complesso e assicurarsi di avere solo uno se avete bisogno.)

Mostrare il modo

Mostriamo quindi semplicemente la modalità SweetAlert2 usando Swal.fire(). Nota che abbiamo un sacco di opzioni qui.

All'apertura controlla la presenza di una chiave di memorizzazione sessione SWEETALERT_HISTORY_RESTORED_KEY che è impostato quando la storia è restaurata. Se lo è, chiudiamo immediatamente il modal (si salva HTMX incasinarci con la gestione della cronologia dispari).

Incendiamo anche un evento. sweetalert:opened che puoi usare per fare qualsiasi logica personalizzata di cui hai bisogno.

Swal.fire({
    title: 'Loading...',
    allowOutsideClick: false,
    allowEscapeKey: false,
    showConfirmButton: false,
    theme: 'dark',
    didOpen: () => {
        // Cancel immediately if restored from browser history
        if (sessionStorage.getItem(SWEETALERT_HISTORY_RESTORED_KEY) === 'true') {
            sessionStorage.removeItem(SWEETALERT_HISTORY_RESTORED_KEY);
            Swal.close();
            return;
        }

        Swal.showLoading();
        document.dispatchEvent(new CustomEvent('sweetalert:opened'));

        // Set timeout to auto-close if something hangs
        clearTimeout(swalTimeoutHandle);
        swalTimeoutHandle = setTimeout(() => {
            if (Swal.isVisible()) {
                console.warn('SweetAlert loading modal closed after timeout.');
                closeSweetAlertLoader();
            }
        }, SWEETALERT_TIMEOUT_MS);
    },
    didClose: () => {
        document.dispatchEvent(new CustomEvent('sweetalert:closed'));
        sessionStorage.removeItem(SWEETALERT_PATH_KEY);
        clearTimeout(swalTimeoutHandle);
        swalTimeoutHandle = null;
    }
});

Inoltre fissiamo un timeout per gestire i casi in cui la richiesta è sospesa. Questo è importante in quanto HTMX non chiude sempre il modal se la richiesta fallisce (soprattutto se si utilizza hx-boost). Questo è impostato qui const SWEETALERT_TIMEOUT_MS = 10000; // Timeout for automatically closing the loader così possiamo chiudere il modal se qualcosa va storto (si accede anche alla console).

Chiudete tutto.

Quindi ora abbiamo il modo aperto, dobbiamo chiuderlo quando la richiesta è finita. Per fare questo noi chiamiamo il maybeClose funzione. Questo viene chiamato quando la richiesta è terminata (con successo o con un errore). Uso htmx:afterRequest e htmx:responseError eventi. Questi eventi sparano una volta che HTMX ha finito una richiesta (nota, questi sono importanti, especialy per HX-Boost che può essere un po 'divertente su ciò che gli eventi spara.)

    document.body.addEventListener('htmx:afterRequest', maybeClose);
    document.body.addEventListener('htmx:responseError', maybeClose);
    function maybeClose(evt) {
        const activePath = sessionStorage.getItem(SWEETALERT_PATH_KEY);
        const path = getRequestPath(evt.detail);

        if (activePath && path && activePath === path) {
            Swal.close();
            sessionStorage.removeItem(SWEETALERT_PATH_KEY);
        }
    }

Vedrai che questa funzione controlla se il percorso nello storage di sessione è lo stesso del percorso della richiesta. Se lo è, chiude il modal e rimuove il percorso dallo storage di sessione.

E' storia.

HTMX ha un modo astuto di gestire la cronologia che potrebbe lasciare il modal 'blocco' aperto nel fare una pagina posteriore. Quindi aggiungiamo un paio di altri eventi per catturare questo (la maggior parte delle volte abbiamo bisogno solo di una cintura e bretelle).

    //Add events to close the loader
    document.body.addEventListener('htmx:afterRequest', maybeClose);
    document.body.addEventListener('htmx:responseError', maybeClose);
    document.body.addEventListener('sweetalert:close', closeSweetAlertLoader);

    // Set a flag so we can suppress the loader immediately if navigating via browser history
    document.body.addEventListener('htmx:historyRestore', () => {
        sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true');
    });

    window.addEventListener('popstate', () => {
        sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true');
    });`

Vedrete che abbiamo anche impostato il sessionStorage.setItem(SWEETALERT_HISTORY_RESTORED_KEY, 'true'); che controlliamo nel didOpen evento:

           didOpen: () => {
    // Cancel immediately if restored from browser history
    if (sessionStorage.getItem(SWEETALERT_HISTORY_RESTORED_KEY) === 'true') {
        sessionStorage.removeItem(SWEETALERT_HISTORY_RESTORED_KEY);
        Swal.close();
        return;
    }

Lo facciamo nel caso in cui il modal non si apra immediatamente su popstate \ htmx:historyRestore (soprattutto se ha un sacco di storia). Quindi dobbiamo controllarlo nel didOpen evento (da cui è in chiave di sessione, a volte questo può ricaricare ecc... quindi dobbiamo essere consapevoli di questo).

In conclusione

Ecco come si può usare SweetAlert2 come indicatore di carico HTMX. Si tratta di un po 'di un hack, ma funziona ed è un bel modo per utilizzare la stessa libreria sia per gli indicatori di caricamento e le finestre di conferma.9

logo

©2024 Scott Galloway