Back to "Mostrar tostada y intercambio de contenido con HTMX (y ASP.NET Core)"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

ASP.NET Core HTMX

Mostrar tostada y intercambio de contenido con HTMX (y ASP.NET Core)

Saturday, 12 April 2025

Introducción

HTMX es una gran biblioteca para hacer sus aplicaciones web más dinámicas y sensibles. En este post, te mostraré cómo usar HTMX para mostrar una notificación de brindis e intercambiar contenido en la página.

Una de las 'limitaciones' en el HTMX estándar es que por lo general sólo tiene una sola pieza de contenido intercambiado desde el extremo posterior. Sin embargo, esto se puede superar con el uso de HX-Trigger cabeceras y un poco de javascript.

TOAST

He estado usando esto una variante de este simple sistema de notificación de tostadas por un tiempo ahora. Es una función simple que toma un mensaje, duración y tipo (éxito, error, advertencia) y muestra una notificación de brindis en la página.

Esta'versión más reciente' tiene algunos más bling alrededor de iconos, animaciones, etc...

El Javascript

// HTMX toast notification
// Simple HTMX toast handler for use with hx-on::after-request
window.showToast = (message, duration = 3000, type = 'info') => {
    const toast = document.getElementById('toast');
    const toastMessage = document.getElementById('toast-message');
    const toastText = document.getElementById('toast-text');
    const toastIcon = document.getElementById('toast-icon');

    // Reset classes
    toastMessage.className = 'alert shadow-lg gap-2 transition-all duration-300 ease-in-out cursor-pointer';
    toastIcon.className = 'bx text-2xl';

    // Add DaisyUI alert type
    const alertClass = `alert-${type}`;
    toastMessage.classList.add(alertClass);

    // Add icon class
    const iconMap = {
        success: 'bx-check-circle',
        error: 'bx-error-circle',
        warning: 'bx-error',
        info: 'bx-info-circle'
    };
    const iconClass = iconMap[type] || 'bx-bell';
    toastIcon.classList.add(iconClass);

    // Set the message
    toastText.textContent = message;

    // Add slide-in animation
    toastMessage.classList.add('animate-slide-in');
    toast.classList.remove('hidden');

    // Allow click to dismiss
    toastMessage.onclick = () => hideToast();

    // Auto-dismiss
    clearTimeout(window.toastTimeout);
    window.toastTimeout = setTimeout(() => hideToast(), duration);

    function hideToast() {
        toastMessage.classList.remove('animate-slide-in');
        toastMessage.classList.add('animate-fade-out');
        toastMessage.onclick = null;

        toastMessage.addEventListener('animationend', () => {
            toast.classList.add('hidden');
            toastMessage.classList.remove('animate-fade-out');
        }, { once: true });
    }
};

Esto utiliza un pequeño fragmento HTML que defino en mi _Archivo Layout.cshtml (usando mi preferido Coilwind CSS & DaisyUI). Note el 'bloque de conservación de clase' al final. Este es un pequeño truco para asegurar que las clases se conservan en la salida final HTML. Esto es realmente para mi configuración de viento de cola como sólo miro cshtml.

<div
        id="toast"
        class="toast toast-bottom fixed z-50 hidden w-full md:w-auto max-w-sm right-4 bottom-4"
>
    <div
            id="toast-message"
            class="alert shadow-lg gap-2 transition-all duration-300 ease-in-out cursor-pointer"
    >
        <i id="toast-icon" class="bx text-2xl"></i>
        <span id="toast-text">Notification message</span>
    </div>
</div>

<!-- class-preserving dummy block -->
<div class="hidden">
    <div class="alert alert-success alert-error alert-warning alert-info"></div>
    <i class="bx bx-check-circle bx-error-circle bx-error bx-info-circle bx-bell"></i>
    <div class="animate-slide-in animate-fade-out"></div>
</div>

Viento de cola

Aquí defino de qué archivos a 'tree-shake' y defino algunas clases de animación que usa el brindis.

const defaultTheme = require("tailwindcss/defaultTheme");

module.exports = {
  content: ["./Views/**/*.cshtml", "./Areas/**/*.cshtml"],
  safelist: ["dark"],
  darkMode: "class",
  theme: {
    extend: {
      keyframes: {
        'slide-in': {
          '0%': { opacity: 0, transform: 'translateY(20px)' },
          '100%': { opacity: 1, transform: 'translateY(0)' },
        },
        'fade-out': {
          '0%': { opacity: 1 },
          '100%': { opacity: 0 },
        },
      },
      animation: {
        'slide-in': 'slide-in 0.3s ease-out',
        'fade-out': 'fade-out 0.5s ease-in forwards',
      },
  },
  plugins: [require("daisyui")],
};

Activado

El secreto de hacer que todo esto funcione es usar el Funcionalidad del encabezado HTMX Trigger.

Ahora "normalmente" lo harías defina esto en su código html / maquinilla de afeitar real:

<div hx-get="/clicked" hx-trigger="click[ctrlKey]">Control Click Me</div>

O puede definirlo en un evento después de la solicitud. Así que haces algo y luego desencadena un nuevo evento.

<button 
    hx-get="/api/do-something"
    hx-swap="none"
    hx-on::afterRequest="window.showToast('API call complete!', 3000, 'success')"
    class="btn btn-primary"
>
    Do Something
</button>

Esto es útil si usted sólo quiere 'hacer algo entonces indicar que está hecho' pero en mi caso quiero intercambiar algo de contenido Y mostrar un brindis.

            Response.Headers.Append("HX-Trigger", JsonSerializer.Serialize(new
            {
                showToast = new
                {
                    toast = result.Message,
                    issuccess = result.Success
                }
            }));

En mi caso mi gatillo se llama showToast y paso un mensaje y una bandera de éxito. Así que mi JS he definido un oyente de eventos para este evento. Esto entonces llama a la showToast función y pasa en el mensaje y la bandera de éxito.

// Handles HX-Trigger: { "showToast": { "toast": "...", "issuccess": true } }
document.body.addEventListener("showToast", (event) => {
    const { toast, issuccess } = event.detail || {};
    const type = issuccess === false ? 'error' : 'success';
    showToast(toast || 'Done!', 3000, type);
});

ASP.NET

Entonces, ¿por qué uso esto? Bueno, en un proyecto de trabajo reciente quería tomar alguna acción sobre un usuario que se muestra en una tabla. Quería mostrar una notificación de brindis e intercambiar el contenido de la fila de usuario con el nuevo contenido.

userrow.png

Como pueden ver, tengo un BUNCH de botones que 'hacen cosas' al usuario. Quería mostrar una notificación de brindis e intercambiar el contenido de la fila de usuario con el nuevo contenido.

Así que en mi controlado tengo un simple'switch' que toma el nombre de la acción, hace cosas y luego devuelve el nuevo resultado de la solicitud.

    private async Task ApplyAction(string email, string useraction)
    {
        if (!string.IsNullOrWhiteSpace(useraction) &&
            Enum.TryParse<UserActionType>(useraction, true, out var parsedAction))
        {
            RequestResult result;

            switch (parsedAction)
            {
                case UserActionType.FlipRoles:
                    result = await userActionService.FlipRestaurantPermissions(email);
                    break;
                case UserActionType.UnflipRoles:
                    result = await userActionService.UnFlipRestaurantPermissions(email);
                    break;
                case UserActionType.Enable2FA:
                    result = await userActionService.ToggleMFA(email, true);
                    break;
                case UserActionType.Disable2FA:
                    result = await userActionService.ToggleMFA(email, false);
                    break;~~~~
                case UserActionType.RevokeTokens:
                    result = await userActionService.RevokeTokens(email);
                    break;
                case UserActionType.Lock:
                    result = await userActionService.Lock(email);
                    break;
                case UserActionType.Unlock:
                    result = await userActionService.Unlock(email);
                    break;
                case UserActionType.Nuke:
                    result = await userActionService.Nuke(email);
                    break;
                case UserActionType.Disable:
                    result = await userActionService.DisableUser(email);
                    break;
                case UserActionType.Enable:
                    result = await userActionService.EnableUser(email);
                    break;
                case UserActionType.ResetPassword:
                    result = await userActionService.ChangePassword(email);
                    break;
                case UserActionType.SendResetEmail:
                    result = await userActionService.SendResetEmail(email);
                    break;
                default:
                    result = new RequestResult(false, "Unknown action");
                    break;
                  
            }

            Response.Headers.Append("HX-Trigger", JsonSerializer.Serialize(new
            {
                showToast = new
                {
                    toast = result.Message,
                    issuccess = result.Success
                }
            }));

        }
    }

Usted puede ver que también append el HX-Trigger cabecera de la respuesta. Este es un objeto JSON con el showToast clave y un valor de un objeto con el toast y issuccess Las llaves. Los toast clave es el mensaje a mostrar en la notificación de brindis y el issuccess clave es un booleano que indica si la acción tuvo éxito o no.

Entonces en el _Row parcial Tengo los atributos HX (usando HTMX.Net) para activar la acción.

                     <!-- Revoke Login Tokens -->
                            <button class="btn btn-xs btn-error border whitespace-normal text-wrap tooltip tooltip-left" data-tip="Revoke login tokens"
                                    hx-get hx-indicator="#loading-modal" hx-target="closest tr" hx-swap="outerHTML"
                                    hx-action="Row" hx-controller="Users"
                                    hx-route-email="@user.Email" hx-route-useraction="@UserActionType.RevokeTokens"
                                    hx-confirm="Are you sure you want to revoke the login tokens for this user?">
                                <i class="bx bx-power-off"></i> Revoke
                            </button>

Puedes ver que uso el objetivo. closest tr para intercambiar toda la fila con el nuevo contenido. Esta es una manera sencilla de actualizar el contenido de la fila sin tener que hacer una actualización completa de la página.

Vista parcial

Esto es realmente muy simple y una gran técnica para ASP.NET Core con HTMX. Puede utilizar opcionalmente HTMX.Nets Request.IsHtmx` aquí, pero en este caso sólo uso esto de una devolución de llamada HTMX.

    [Route("row")]
 
    public async Task<IActionResult> Row(string email, string? useraction = null)
    {

        if(!string.IsNullOrEmpty(useraction))
          await ApplyAction(email, useraction);

        var userRow = await userViewService.GetSingleUserViewModel(email);
        return PartialView("_Row", userRow);
    }

En este caso la vista parcial _Row es una fila de tabla simple con la información del usuario y los botones para realizar las acciones.

Características adicionales de HTMX

También uso un par de funciones HTMX más para mejorar la experiencia del usuario.

Cargando

También uso un simple loading modal indicar que la solicitud está en curso. Esta es una manera sencilla de mostrar al usuario que algo está sucediendo en el fondo.

<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>

Confirmar

También uso el hx-confirm para mostrar un diálogo de confirmación antes de realizar la acción. Esta es una manera sencilla de asegurarse de que el usuario realmente quiere realizar la acción. Esto utiliza SweetAlert2 para mostrar un diálogo de confirmación.

Ahora bien, si no hace esto, HTMX todavía funciona, pero utiliza el cuadro de diálogo estándar del navegador 'confirmar' que puede ser un poco molesto para el usuario.

// HTMX confirm with SweetAlert2
window.addEventListener('htmx:confirm', (e) => {
    const message = e.detail.question;
    if (!message) return;

    e.preventDefault();

    Swal.fire({
        title: 'Please confirm',
        text: message,
        icon: 'warning',
        showCancelButton: true,
        confirmButtonText: 'Yes',
        cancelButtonText: 'Cancel',
        theme: 'dark',
    }).then(({ isConfirmed }) => {
        if (isConfirmed) e.detail.issueRequest(true);
    });
});

Conclusión

Esta es una forma sencilla de usar HTMX para mostrar una notificación de brindis e intercambiar contenido en la página. Esta es una gran manera de hacer sus aplicaciones web más dinámico y sensible.

logo

©2024 Scott Galloway