Un ayudante de la etiqueta principal ASP.NET (Parte 2, Tamaño de página) (Español (Spanish))

Un ayudante de la etiqueta principal ASP.NET (Parte 2, Tamaño de página)

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, 17 March 2025

//

Less than a minute

Introducción

Como parte de mi sabio en curso con mi ayudante de etiqueta de paginación, ahora he separado PageSize en su propio ayudante de etiqueta. Esto es para hacer el ayudante de etiqueta más flexible y para permitir escenarios de paginación más complejos.

Una vez más; estas son todas obras en progreso. Ya lo estoy. hasta 0.9.0 en el momento de escribir, pero utilizar en su propio riesgo. Es gratis, pero no todavía de la calidad de lanzamiento IMHO (Ni siquiera tengo muestras completas ¡Todavía!)

Ver la código para el ayudante de etiqueta aquí.

El ayudante de etiqueta de tamaño de página

Como con mis otros taghelpers esto está diseñado para un caso de uso que encuentro a menudo; cómo cambiar fácilmente el tamaño de la página en las listas de resultados. A primera vista esto parece bastante sencillo, pero puede convertirse en TRICKY una vez que se añade HTMX en la mezcla.

Necesidades

Así que para mi caso de uso los requisitos fueron los siguientes

  1. Permite un uso fácil para el desarrollador de teh (tanto como sea posible se configura utilizando un modelo de página simple)
  2. Soporta la actualización del tamaño de la página usando HTMX - lo que significa que es capaz de publicar de nuevo mientras se conservan todos los parámetros de la cadena de consulta. Esto es importante ya que permite al usuario cambiar el tamaño de la página sin perder ningún otro filtro que haya aplicado.
  3. Tiene un rango algo dinámico de tamaños de página (incluyendo una opción 'Todos').

El ayudante de la etiqueta

Con esto en mente diseñé el TagHelper para ser como sigue:

<page-size
    hx-target="#list"
    hx-swap="innerHTML"
    model="Model">
    
</page-size>
<div id="list">
    <partial name="_ResultsList" model="Model"/>
</div>

Así que en este caso de nuevo toma mi modelo de paginación estándar:

public interface IPagingModel
{
    public int Page { get; set; }
    public int TotalItems { get; set; }
    public int PageSize { get; set; }

    public ViewType ViewType { get; set; }
    
    public string LinkUrl { get; set; }
}

Lo que proporciona la Page, TotalItems, PageSize, ViewType y LinkUrl para la paginación.

También puede especificarlos en forma de atributos al modelo:


<page-size
    hx-target="#list"
    hx-swap="innerHTML"
    total-items="100"
    page-size="10"
    view-type="DaisyAndTailwind">
</page-size>

Donde el hx-target aquí está el objetivo para la solicitud HTMX y el hx-swap es el objetivo de la respuesta.

En este caso se utilizará el especificado PageSize y TotalItems y ViewType para renderizar el selector de tamaño de página.

JS Shenanigans

Desafortunadamente esta vez tuve un requisito que no podía manejar puramente lado servidor. Es decir, quería preservar los paramateros de la cadena de consulta en la solicitud de cambio de tamaño de páginas. Para lograr esto tengo dos piezas de JS que se cargan en el control dependiendo de HTMX que se utiliza o no

@if (Model.UseHtmx)
{
@Html.HTMXPageSizeChange()
}
else
{
    @Html.PageSizeOnchangeSnippet()
}

Si se está utilizando HTMX, esto se traduce en la página:

(() => {
    if (window.__pageSizeListenerAdded) return;

    document.addEventListener('htmx:configRequest', event => {
        const { elt } = event.detail;
        if (elt?.matches('[name="pageSize"]')) {
            const params = new URLSearchParams(window.location.search);
            params.set('pageSize', elt.value); // This will update or add the pageSize param
            event.detail.parameters = Object.fromEntries(params.entries());
        }
    });
    window.__pageSizeListenerAdded = true;
})();

Que es una simple pieza de JavaScript que inyectará los parámetros actuales de la cadena de consulta en la solicitud.

Alternativamente, si HTMX NO está habilitado, se hará esto:

(function attachPageSizeListener() {
    // Ensure we only attach once if this script is included multiple times
    if (window.__pageSizeListenerAttached) return;
    window.__pageSizeListenerAttached = true;

    // Wait for the DOM to be fully loaded
    document.addEventListener("DOMContentLoaded", () => {
        document.body.addEventListener("change", handlePageSizeChange);
    });

    function handlePageSizeChange(event) {
        // Check if the changed element is a page-size <select> inside .page-size-container
        const select = event.target.closest(".page-size-container select[name='pageSize']");
        if (!select) return;

        const container = select.closest(".page-size-container");
        if (!container) return;

        // Default to "true" if there's no .useHtmx input
        const useHtmx = container.querySelector("input.useHtmx")?.value === "true";
        if (useHtmx) {
            // If using HTMX, we do nothing—HTMX will handle the request
            return;
        }

        // Either use a linkUrl from the container or the current page URL
        const linkUrl = container.querySelector("input.linkUrl")?.value || window.location.href;
        const url = new URL(linkUrl, window.location.origin);

        // Copy existing query params from current location
        const existingParams = new URLSearchParams(window.location.search);
        for (const [key, value] of existingParams.entries()) {
            url.searchParams.set(key, value);
        }

        // If user picked the same pageSize as what's already in the URL, do nothing
        if (url.searchParams.get("pageSize") === select.value) {
            return;
        }

        // Update the pageSize param
        url.searchParams.set("pageSize", select.value);

        // Redirect
        window.location.href = url.toString();
    }
})();

Un cambio que haré lilely es cambiar el nombre de la variable 'bandera'; __pageSizeListenerAttached ya que es un poco demasiado genérico Y lo uso tanto para solicitudes HTMX y no HTMX.

El CÓDIGO

El tag helper en sí mismo es bastante simple, el código principal sólo pobla algunas propiedades y luego renderiza el componente de vista.

 public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        // We want to render a <div> by default
        output.TagName = "div";

        // Remove any leftover attributes that we handle ourselves
        RemoveUnwantedAttributes(output);

        // Determine final PagerId
        var pagerId = PagerId ?? PageSizeModel?.PagerId ?? $"pager-{Guid.NewGuid():N}";
        // Assign ID to the outer div
        output.Attributes.SetAttribute("id", pagerId);
        // Build or fallback to a link URL
        var finalLinkUrl = BuildLinkUrl();
        if (string.IsNullOrEmpty(finalLinkUrl))
        {
            // If we can't build a URL, show a fallback message or short-circuit
            output.Content.SetHtmlContent(
                "<p style=\"color:red\">No valid link URL found for PageSize control.</p>");
            return;
        }

        var finalPageSize = PageSize ?? PageSizeModel?.PageSize ?? 10;
        // Clamp the page size to MaxPageSize

        var finalTotalItems = TotalItems ?? Model?.TotalItems ?? PageSizeModel?.TotalItems ?? 0;
        if (finalTotalItems == 0) throw new ArgumentNullException(nameof(finalTotalItems), "TotalItems is required");
        var maxPageSize = Math.Min(finalTotalItems, MaxPageSize);

        // Fallback to model's properties if not set
        var finalViewType = PageSizeModel?.ViewType ?? Model?.ViewType ?? ViewType;

        var pageSizeSteps = new int[] { 10, 25, 50, 75, 100, 125, 150, 200, 250, 500, 1000 };

        
        if (!string.IsNullOrEmpty(PageSizeSteps))
            pageSizeSteps = PageSizeSteps.Split(',').Select(s =>

            {
                if (int.TryParse(s, out var result))
                    return result;
                else
                {
                    throw new ArgumentException("Invalid page size step", nameof(PageSizeSteps));
                }
            }).ToArray();


        var pageSizes = CalculatePageSizes(finalTotalItems, maxPageSize, pageSizeSteps);

        var useHtmx = PageSizeModel?.UseHtmx ?? UseHtmx;
        var useLocalView = PageSizeModel?.UseLocalView ?? UseLocalView;

        // Acquire the IViewComponentHelper
        var viewComponentHelper = ViewContext.HttpContext.RequestServices
            .GetRequiredService<IViewComponentHelper>();
        ((IViewContextAware)viewComponentHelper).Contextualize(ViewContext);

        // Construct the PageSizeModel (if not provided) with final settings
        var pagerModel = new PageSizeModel
        {
            ViewType = finalViewType,
            UseLocalView = useLocalView,
            UseHtmx = useHtmx,
            PageSizes = pageSizes,
            PagerId = pagerId,
            Model = Model,
            LinkUrl = finalLinkUrl,
            MaxPageSize = maxPageSize,
            PageSize = finalPageSize,
            TotalItems = finalTotalItems
        };

        // Safely invoke the "PageSize" view component
        try
        {
            var result = await viewComponentHelper.InvokeAsync("PageSize", pagerModel);
            output.Content.SetHtmlContent(result);
        }
        catch (Exception ex)
        {
            // Optional: Log or display an error
            output.Content.SetHtmlContent(
                $"<p class=\"text-red-500\">Failed to render PageSize component: {ex.Message}</p>"
            );
        }
    }

Una pieza difícil está manejando correctamente el PageSizeSteps atributo. Esta es una lista separada por comas de los tamaños de página que el usuario puede seleccionar. O puedes pasar en tus propios pasos. También puede especificar el tamaño MaxPageSize que es el tamaño máximo de página que se puede seleccionar.

    private List<int> CalculatePageSizes(int totalItems, int maxxPageSize, int[] pageSizeSteps)
    {
        var pageSizes = new List<int>();

        // 1. Include all fixed steps up to both TotalItems and MaxPageSize
        foreach (var step in pageSizeSteps)
        {
            if (step <= totalItems && step <= maxxPageSize)
                pageSizes.Add(step);
        }

        // 2. If TotalItems exceeds the largest fixed step, keep doubling
        int lastFixedStep = pageSizeSteps.Last();
        if (totalItems > lastFixedStep)
        {
            int next = lastFixedStep;
            while (next < totalItems && next < maxxPageSize)
            {
                next *= 2; // double the step
                if (next <= totalItems && next <= maxxPageSize)
                {
                    pageSizes.Add(next);
                }
            }
        }

        // 3. Include TotalItems if it's not already in the list
        if (totalItems <= maxxPageSize && !pageSizes.Contains(totalItems))
        {
            pageSizes.Add(totalItems);
        }

        pageSizes.Sort();

        return pageSizes;
    }

Como con mi otro ayudante de etiquetas, utilizo el patrón TagHelper->ViewComponent para representar el componente de vista. Esto me permite mantener el ayudante de etiqueta simple y el complejo de componentes de vista. También le permite cambiar puntos de vista para diferentes experiencias; o incluso inyectar su propia vista y utilizar que (usando use-local-view).


public class PageSizeViewComponent : ViewComponent
    {
        public IViewComponentResult Invoke(PageSizeModel model)
        {
            if(model.Model != null)
            {
                model.LinkUrl ??= model.Model.LinkUrl;
                
                if(model.Model is IPagingSearchModel searchModel)
                {
                    model.SearchTerm ??= searchModel.SearchTerm;
                }
            }
            
            var viewName = "Components/Pager/Default";

            var useLocalView = model.UseLocalView;

            return (useLocalView, model.ViewType) switch
            {
                (true, ViewType.Custom) when ViewExists(viewName) => View(viewName, model),
                (true, ViewType.Custom) when !ViewExists(viewName) => throw new ArgumentException("View not found: " + viewName),
                (false, ViewType.Bootstrap) => View("/Areas/Components/Views/PageSize/BootstrapView.cshtml", model),
                (false, ViewType.Plain) => View("/Areas/Components/Views/PageSize/PlainView.cshtml", model),
                (false, ViewType.TailwindAndDaisy) => View("/Areas/Components/Views/PageSize/Default.cshtml", model),
                _ => View("/Areas/Components/Views/PageSize/Default.cshtml", model)
            };

            // If the view exists in the app, use it; otherwise, use the fallback RCL view
        }
        /// <summary>
        /// Checks if a view exists in the consuming application.
        /// </summary>

        private bool ViewExists(string viewName)
        {
            var result = ViewEngine.FindView(ViewContext, viewName, false);
            return result.Success;
        }
    }

Conclusión

Bueno, eso es todo para el ayudante de la etiqueta PageSize. Todavía estoy trabajando en la documentación y ejemplos, pero el código es gettign mejor a diario.

logo

©2024 Scott Galloway